// running/src/marathonAnalysis.jsx — slice 8 of the module split.
//
// MARATHON ANALYSIS — deep per-race cards + cross-race progression.
// The app keeps only ~200 recent activities in memory, so this view
// runs its own unbounded Firestore query to reach marathons back to
// 2023. Extracted verbatim from running/index.html; the
// MARATHON_REGISTRY / MARATHON_DEFAULT_NOTES seed data moves with it
// (nothing else references them). db/functions resolve from the
// page-level firebase script.

const { C, Card, EmptyState } = window.SharedUI;
const { useState, useEffect, useMemo } = React;
const {
  AreaChart, Area, LineChart, Line, Bar, ComposedChart, Cell,
  XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
  ReferenceLine,
} = Recharts;

    // ll = approximate start coordinates, used for the race-day weather
    // lookup when the synced activity has no GPS coordinates of its own.
    const MARATHON_REGISTRY = [
      { key:"de2023", name:"Delaware Coastal Marathon", loc:"Lewes, DE",          ll:[38.776,-75.139], year:2023, dnf:true,  rx:/delaware|coastal/i },
      { key:"ph2023", name:"Philadelphia Marathon",     loc:"Philadelphia, PA",   ll:[39.966,-75.181], year:2023, dnf:false, rx:/philadelph|philly/i },
      { key:"to2024", name:"Toronto Marathon",          loc:"Toronto, ON",        ll:[43.651,-79.382], year:2024, dnf:true,  rx:/toronto/i },
      { key:"sh2025", name:"Shamrock Marathon",         loc:"Virginia Beach, VA", ll:[36.853,-75.978], year:2025, dnf:false, rx:/shamrock/i },
      { key:"ph2025", name:"Philadelphia Marathon",     loc:"Philadelphia, PA",   ll:[39.966,-75.181], year:2025, dnf:false, rx:/philadelph|philly/i },
    ];

    // Seed first-hand race context the data can't show. Used until the
    // athlete edits the notes field on a card (then their text is saved).
    const MARATHON_DEFAULT_NOTES = {
      de2023: "Near 80°F by mile 20-21. Very exposed course, and aid stations were lacking in the final quarter.",
      to2024: "Stomach issues during the race — the fuel I took on didn't sit well or stay down. Hit the wall hard.",
      sh2025: "Headwind from mile 7 through mile 18-19, then mixed/variable after that.",
    };

    function MarathonAnalysis({ userId, isOwner }) {
      const MI = 1609.34, FULL = 42195;
      const [acts, setActs] = useState(null);   // full run history; null = loading
      const [detail, setDetail] = useState({}); // { [id]: "loading"|{laps,streams,error?} }
      const [weather, setWeather] = useState({}); // { [id]: "loading"|"error"|{...} }
      const [insight, setInsight] = useState({}); // { [id]: "loading"|null|{...}|{error} }
      const [notes, setNotes] = useState({});   // { [id]: athlete's race-notes text }
      const [err, setErr] = useState(null);
      const [planRace, setPlanRace] = useState(null); // { date, name, weeks } from active plan
      const [alignMode, setAlignMode] = useState("race"); // "race" | "start" — build-comparison x-axis

      useEffect(() => {
        if (!userId) return;
        let dead = false;
        db.collection("users").doc(userId).collection("activities")
          .orderBy("start_date", "desc").limit(2000).get()
          .then(snap => { if (!dead) setActs(snap.docs.map(d => ({ id: d.id, ...d.data() }))); })
          .catch(e => { if (!dead) setErr((e && e.message) || "load failed"); });
        return () => { dead = true; };
      }, [userId]);

      // Active plan's goal-race date — anchors the "current block" line of the
      // build-comparison chart on weeks-to-race, the same axis the past
      // builds are aligned to.
      useEffect(() => {
        if (!userId) return;
        let dead = false;
        db.collection("users").doc(userId).collection("trainingPlans")
          .where("status", "==", "active").limit(1).get()
          .then(snap => {
            if (dead || snap.empty) return;
            const p = snap.docs[0].data();
            const d = p.raceDate ? (p.raceDate.toDate ? p.raceDate.toDate() : new Date(p.raceDate)) : null;
            if (d && !isNaN(d.getTime())) {
              setPlanRace({ date: d, name: p.raceName || "Goal race", weeks: (p.weeks?.length || p.weeksTotal || null) });
            }
          })
          .catch(() => {});
        return () => { dead = true; };
      }, [userId]);

      const toDate = (v) => {
        if (!v) return null;
        if (typeof v.toDate === "function") return v.toDate();
        const d = new Date(v); return isNaN(d.getTime()) ? null : d;
      };
      const fmtHMS = (s) => {
        s = Math.max(0, Math.round(s || 0));
        const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), ss = s % 60;
        return h > 0 ? h + ":" + String(m).padStart(2, "0") + ":" + String(ss).padStart(2, "0")
                     : m + ":" + String(ss).padStart(2, "0");
      };
      const fmtPace = (sec) => {
        if (!sec || !isFinite(sec)) return "--";
        return Math.floor(sec / 60) + ":" + String(Math.round(sec % 60)).padStart(2, "0");
      };
      const median = (arr) => {
        if (!arr.length) return 0;
        const s = [...arr].sort((a, b) => a - b), m = Math.floor(s.length / 2);
        return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
      };

      // Match each registry race to a real activity.
      const matched = useMemo(() => {
        if (!acts) return [];
        const runs = acts.filter(a => {
          const t = a.type || "";
          return (t === "Run" || t === "VirtualRun") && (a.distance || 0) > 5000;
        });
        const closest = (list, target) => list.slice()
          .sort((a, b) => Math.abs((a.distance || 0) - target) - Math.abs((b.distance || 0) - target))[0] || null;
        return MARATHON_REGISTRY.map(entry => {
          const inYear = runs.filter(r => { const d = toDate(r.start_date); return d && d.getFullYear() === entry.year; });
          const named = inYear.filter(r => entry.rx.test(r.name || ""));
          let act = null;
          if (entry.dnf) {
            act = named.length ? closest(named, FULL)
              : closest(inYear.filter(r => (r.distance || 0) >= 18000 && (r.distance || 0) < 41000), FULL);
          } else {
            const full = (named.length ? named : inYear).filter(r => (r.distance || 0) >= 41000 && (r.distance || 0) <= 46000);
            act = full.length ? closest(full, FULL) : (named.length ? closest(named, FULL) : null);
          }
          return { entry, act };
        });
      }, [acts]);

      // Build-comparison data: the current block plus ghost lines of the past
      // marathon builds. Two alignment modes share the same per-week stats:
      //   • "race"  → x = weeks-to-race, so tapers/peaks line up at the race.
      //   • "start" → x = weeks-into-build from each build's own first real
      //               week, so ramps line up regardless of how early they began.
      // Each week carries miles + longest-run + run-count, and a "gap" flag
      // (a 0-run week AFTER the build was under way — a missed/down week).
      const buildCmp = useMemo(() => {
        if (!acts) return null;
        const WK = 7 * 864e5, now = Date.now();
        const ghostColors = [C.purple, C.amber, C.green, C.pink, C.cyanMuted];
        const MAXW = 34; // generous raw window; framed per mode below

        // Per-week {miles, longest, runs, eff} for the MAXW weeks before
        // raceMs, oldest first. Future weeks (nullFuture) are left null so the
        // current line stops at "now".
        //
        // Week buckets are aligned to Mon-Sun calendar weeks — the same
        // convention getFitnessData() uses for the weekly-mile signal sent
        // to the plan generator — so this chart can't disagree with the
        // Plan / Status views about "what did I run last week". The end
        // anchor is the Monday AFTER race day, so the race-week bucket
        // spans Mon-Sun and contains the race regardless of whether it
        // falls on a Saturday, Sunday, or other day.
        //
        // Aerobic efficiency = meters per heartbeat on easy/neutral runs only
        // (excludes races + workouts so HR drift doesn't muddy the trend).
        // Higher = same pace at lower HR, i.e. fitness gain. Duration-weighted.
        const weeklyStats = (raceMs, nullFuture) => {
          const raceDate = new Date(raceMs);
          const dayOfWeek = raceDate.getDay(); // 0=Sun ... 6=Sat
          const daysToNextMonday = ((8 - dayOfWeek) % 7) || 7;
          const endAnchor = new Date(raceDate);
          endAnchor.setDate(raceDate.getDate() + daysToNextMonday);
          endAnchor.setHours(0, 0, 0, 0);
          const start = endAnchor.getTime() - MAXW * WK;
          const out = [];
          const RM = window.RaceMath;
          for (let w = 0; w < MAXW; w++) {
            const ws = start + w * WK, we = ws + WK;
            if (nullFuture && ws > now) { out.push(null); continue; }
            let miles = 0, longest = 0, runs = 0, quality = 0;
            let efBeatMeters = 0, efDur = 0;
            for (const x of acts) {
              // Drop family walks / dog-walk-jogs paced > 12:00/mi — they
              // aren't training stimulus and shouldn't inflate the line.
              // Falls back to the bare type check if RaceMath isn't loaded.
              if (RM ? !RM.isTrainingRun(x) : (x.type !== "Run" && x.type !== "VirtualRun")) continue;
              const d = toDate(x.start_date); if (!d) continue;
              const ms = d.getTime();
              if (ms >= ws && ms < we) {
                const m = (x.distance || 0) / MI;
                miles += m; if (m > longest) longest = m; runs++;
                const intent = RM ? RM.classifyRunIntent(x) : "neutral";
                if (x.workout_type === 1 || x.workout_type === 3 || intent === "race" || intent === "hard") quality++;
                if ((intent === "easy" || intent === "neutral") &&
                    x.average_heartrate > 80 && x.distance > 800 && x.moving_time > 60) {
                  const mPerBeat = (x.distance / x.moving_time) * 60 / x.average_heartrate;
                  efBeatMeters += mPerBeat * x.moving_time;
                  efDur += x.moving_time;
                }
              }
            }
            const eff = efDur > 0 ? +(efBeatMeters / efDur).toFixed(2) : null;
            out.push({ miles: +miles.toFixed(1), longest: +longest.toFixed(1), runs, quality, eff });
          }
          return out;
        };

        // First "real" build week: first of 2 consecutive weeks at ≥ a
        // fitness-scaled floor, so a stray base run far out doesn't anchor it.
        const buildStartIdx = (stats) => {
          const peak = stats.reduce((m, s) => s ? Math.max(m, s.miles) : m, 0);
          const floor = Math.max(8, peak * 0.25);
          for (let i = 0; i < stats.length - 1; i++) {
            if (stats[i] && stats[i].miles >= floor && stats[i + 1] && stats[i + 1].miles >= floor) return i;
          }
          return stats.findIndex(s => s && s.miles > 0);
        };

        // Flag down/missed weeks: 0 runs once the build is under way.
        const flagGaps = (stats) => {
          let started = false;
          return stats.map(s => {
            if (!s) return s;
            if (s.runs > 0) started = true;
            return { ...s, gap: started && s.runs === 0 };
          });
        };

        const mkSeries = (raceMs, nullFuture) => flagGaps(weeklyStats(raceMs, nullFuture));

        const priors = matched.filter(m => m.act)
          .map(m => ({ key: m.entry.key, entry: m.entry, raceMs: (toDate(m.act.start_date) || new Date()).getTime() }))
          .sort((a, b) => b.raceMs - a.raceMs)
          .slice(0, 5)
          .map((p, i) => ({
            ...p, color: ghostColors[i % ghostColors.length], isCur: false,
            name: p.entry.loc.split(",")[0] + " '" + String(p.entry.year).slice(2) + (p.entry.dnf ? " (DNF)" : ""),
            stats: mkSeries(p.raceMs, false),
          }));

        const curStats = planRace?.date ? mkSeries(planRace.date.getTime(), true) : null;
        const series = [
          ...(curStats ? [{ key: "cur", name: "Current block", color: C.cyan, isCur: true, dnf: false, stats: curStats }] : []),
          ...priors.map(p => ({ key: p.key, name: p.name, color: p.color, isCur: false, dnf: p.entry.dnf, stats: p.stats })),
        ];

        // Per-build consistency stats for the at-a-glance row.
        const stats = series.map(s => {
          const startI = buildStartIdx(s.stats);
          const inBuild = startI >= 0 ? s.stats.slice(startI).filter(Boolean) : [];
          return {
            key: s.key, name: s.name, color: s.color, dnf: s.dnf, isCur: s.isCur,
            peak: inBuild.reduce((m, x) => Math.max(m, x.miles), 0),
            downWeeks: inBuild.filter(x => x.gap || (x.runs > 0 && x.miles < 0.4 * (inBuild.reduce((mm, y) => Math.max(mm, y.miles), 0)) && x.runs <= 1)).length,
            longRunWeeks: inBuild.filter(x => x.longest >= 18).length,
            buildLen: inBuild.length,
          };
        });

        // Frame the rows for the active alignment mode. Capped at 22 weeks so
        // a prior build's tail doesn't bleed in at the left edge.
        const MAX_FRAME = 22;
        let volData, lrData, effData, xKey, xDomain, xFmt, nowX = null;
        if (alignMode === "start") {
          // x = week number from each build's own start (1-based).
          xKey = "wk"; xFmt = (v) => `w${v}`;
          const framed = series.map(s => {
            const si = buildStartIdx(s.stats);
            return { s, si: si < 0 ? 0 : si };
          });
          const maxLen = Math.min(MAX_FRAME, Math.max(1, ...framed.map(f => f.s.stats.length - f.si)));
          xDomain = [1, maxLen];
          volData = []; lrData = []; effData = [];
          for (let w = 1; w <= maxLen; w++) {
            const vr = { wk: w }, lr = { wk: w }, er = { wk: w };
            framed.forEach(({ s, si }) => {
              const cell = s.stats[si + w - 1];
              vr[s.key] = cell ? cell.miles : null;
              vr[s.key + "_gap"] = cell ? !!cell.gap : false;
              lr[s.key] = cell ? cell.longest : null;
              er[s.key] = cell ? cell.eff : null;
              if (s.isCur && cell) nowX = w;
            });
            volData.push(vr); lrData.push(lr); effData.push(er);
          }
        } else {
          // x = weeks-to-race over the framing window N (capped at 22).
          const N = Math.min(MAX_FRAME, planRace?.weeks || MAX_FRAME);
          xKey = "wk"; xFmt = (v) => v >= -1 ? "race" : `${v}w`;
          xDomain = [-N, -1];
          volData = []; lrData = []; effData = [];
          const base = MAXW - N; // align the last N weeks (…-1 = race week)
          for (let i = 0; i < N; i++) {
            const idx = base + i, wk = i - N;
            const vr = { wk }, lr = { wk }, er = { wk };
            series.forEach(s => {
              const cell = s.stats[idx];
              vr[s.key] = cell ? cell.miles : null;
              vr[s.key + "_gap"] = cell ? !!cell.gap : false;
              lr[s.key] = cell ? cell.longest : null;
              er[s.key] = cell ? cell.eff : null;
              if (s.isCur && cell) nowX = wk;
            });
            volData.push(vr); lrData.push(lr); effData.push(er);
          }
        }

        return { series, stats, volData, lrData, effData, xKey, xDomain, xFmt, nowX, hasCur: !!curStats, priorCount: priors.length };
      }, [acts, matched, planRace, alignMode]);

      // Load cached split detail; auto-fetch from Strava when missing.
      useEffect(() => {
        if (!userId) return;
        matched.forEach(({ act }) => {
          if (!act) return;
          const id = String(act.id);
          if (detail[id] !== undefined) return;
          setDetail(p => p[id] !== undefined ? p : { ...p, [id]: "loading" });
          db.collection("users").doc(userId).collection("activities").doc(id)
            .collection("detail").doc("laps").get()
            .then(doc => {
              const d = doc.exists ? doc.data() : null;
              if (d && ((d.streams && d.streams.length) || (d.laps && d.laps.length)))
                setDetail(p => ({ ...p, [id]: { laps: d.laps || [], streams: d.streams || [] } }));
              else if (isOwner)
                return functions.httpsCallable("fetchActivityDetail")({ activityId: act.id })
                  .then(res => {
                    const r = (res && res.data) || {};
                    setDetail(p => ({ ...p, [id]: { laps: r.laps || [], streams: r.streams || [] } }));
                  });
              else setDetail(p => ({ ...p, [id]: { laps: [], streams: [] } }));
            })
            .catch(e => setDetail(p => ({ ...p, [id]: { laps: [], streams: [], error: (e && e.message) || "fetch failed" } })));
        });
      }, [matched, userId]);
      const retryDetail = async (actId) => {
        const id = String(actId);
        setDetail(p => ({ ...p, [id]: "loading" }));
        try {
          const res = await functions.httpsCallable("fetchActivityDetail")({ activityId: actId });
          const r = (res && res.data) || {};
          setDetail(p => ({ ...p, [id]: { laps: r.laps || [], streams: r.streams || [] } }));
        } catch (e) {
          setDetail(p => ({ ...p, [id]: { laps: [], streams: [], error: (e && e.message) || "fetch failed" } }));
        }
      };

      // Race-day weather — fetched server-side (the Open-Meteo archive API
      // isn't reachable from the browser) via the marathonWeather function.
      useEffect(() => {
        matched.forEach(({ entry, act }) => {
          if (!act) return;
          const id = String(act.id);
          if (weather[id] !== undefined) return;
          const ll = (act.start_latlng && act.start_latlng.length === 2) ? act.start_latlng : entry.ll;
          if (!ll || ll.length !== 2) { setWeather(p => ({ ...p, [id]: "error" })); return; }
          const local = act.start_date_local || (toDate(act.start_date) ? toDate(act.start_date).toISOString() : null);
          if (!local) { setWeather(p => ({ ...p, [id]: "error" })); return; }
          setWeather(p => p[id] !== undefined ? p : { ...p, [id]: "loading" });
          functions.httpsCallable("marathonWeather")({
            lat: ll[0], lng: ll[1],
            date: String(local).slice(0, 10),
            startHour: parseInt(String(local).slice(11, 13), 10) || 9,
            durHours: Math.max(1, Math.ceil((act.elapsed_time || act.moving_time || 3600) / 3600)),
          }).then(res => {
            const d = res && res.data;
            setWeather(p => ({ ...p, [id]: (d && d.tStart != null) ? d : "error" }));
          }).catch(() => setWeather(p => ({ ...p, [id]: "error" })));
        });
      }, [matched]);

      // Cached race-specific marathon analysis.
      useEffect(() => {
        if (!userId) return;
        matched.forEach(({ act }) => {
          if (!act) return;
          const id = String(act.id);
          if (insight[id] !== undefined) return;
          setInsight(p => p[id] !== undefined ? p : { ...p, [id]: null });
          db.collection("users").doc(userId).collection("activities").doc(id)
            .collection("detail").doc("marathonAnalysis").get()
            .then(doc => {
              const d = doc.exists ? doc.data() : null;
              if (d && (d.headline || d.buildup)) {
                setInsight(p => ({ ...p, [id]: d }));
                // Backfill the structured weekly build onto analyses generated
                // before we persisted it, so the coach can quote real volume
                // numbers (it can't recompute a year-old build from its short
                // activity window). One-shot, owner-only, skip when present.
                if (isOwner && !Array.isArray(d.buildupWeeks)) {
                  const rd = toDate(act.start_date);
                  const wks = rd ? buildBuildup(rd) : [];
                  if (wks.length) {
                    db.collection("users").doc(userId).collection("activities").doc(id)
                      .collection("detail").doc("marathonAnalysis")
                      .set({ buildupWeeks: wks }, { merge: true }).catch(() => {});
                  }
                }
              }
            })
            .catch(() => {});
        });
      }, [matched, userId]);

      // Athlete's per-race notes — saved context (course, fuelling, wind,
      // GI issues) fed into the analysis. Falls back to a seeded default.
      useEffect(() => {
        if (!userId) return;
        matched.forEach(({ entry, act }) => {
          if (!act) return;
          const id = String(act.id);
          if (notes[id] !== undefined) return;
          setNotes(p => p[id] !== undefined ? p : { ...p, [id]: MARATHON_DEFAULT_NOTES[entry.key] || "" });
          db.collection("users").doc(userId).collection("activities").doc(id)
            .collection("detail").doc("raceNotes").get()
            .then(doc => {
              if (doc.exists && typeof doc.data().note === "string")
                setNotes(p => ({ ...p, [id]: doc.data().note }));
            })
            .catch(() => {});
        });
      }, [matched, userId]);
      const saveNote = (actId, text) => {
        if (!userId) return;
        db.collection("users").doc(userId).collection("activities").doc(String(actId))
          .collection("detail").doc("raceNotes")
          .set({ note: text, updatedAt: new Date().toISOString() })
          .catch(() => {});
      };

      // 20-week training build-up for a race — weekly volume, long runs and
      // quality, computed from the full history already loaded into acts.
      const buildBuildup = (raceDate) => {
        if (!raceDate || !acts) return [];
        const WK = 7 * 864e5, start = raceDate.getTime() - 20 * WK;
        const runs = acts.filter(x => {
          const t = x.type || "";
          if (t !== "Run" && t !== "VirtualRun") return false;
          const d = toDate(x.start_date);
          return d && d.getTime() >= start && d.getTime() < raceDate.getTime();
        });
        const weeks = [];
        for (let w = 0; w < 20; w++) {
          const ws = start + w * WK, we = ws + WK;
          const wr = runs.filter(x => { const d = toDate(x.start_date); return d && d.getTime() >= ws && d.getTime() < we; });
          weeks.push({
            wk: w - 20,
            miles: +(wr.reduce((s, x) => s + (x.distance || 0), 0) / MI).toFixed(1),
            longest: +(wr.reduce((m, x) => Math.max(m, (x.distance || 0) / MI), 0)).toFixed(1),
            runs: wr.length,
            quality: wr.filter(x => x.workout_type === 1 || x.workout_type === 3 ||
              (window.RaceMath && ["race", "hard"].indexOf(window.RaceMath.classifyRunIntent(x)) >= 0)).length,
          });
        }
        return weeks;
      };

      const genAnalysis = async (r) => {
        const a = r.act, an = r.an, id = String(a.id);
        const fs = an.splits.filter(s => !s.frac);
        const bm = fs.length ? fs.reduce((x, y) => y.sec < x.sec ? y : x) : null;
        const wm = fs.length ? fs.reduce((x, y) => y.sec > x.sec ? y : x) : null;
        const splitU = an.splitSrc === "lap" ? "lap" : "mile";
        setInsight(p => ({ ...p, [id]: "loading" }));
        try {
          const res = await functions.httpsCallable("analyzeMarathon")({
            activityId: a.id,
            race: { name: r.entry.name, year: r.entry.year, location: r.entry.loc, dnf: !!r.entry.dnf },
            result: {
              distanceMi: an.mi, timeSec: an.time, paceSec: an.pace,
              avgHr: a.average_heartrate ? Math.round(a.average_heartrate) : null,
              maxHr: a.max_heartrate ? Math.round(a.max_heartrate) : null,
              elevGainFt: a.total_elevation_gain ? Math.round(a.total_elevation_gain * 3.281) : null,
              vo2: an.vo2,
            },
            execution: an.halves ? {
              firstHalfPace: an.halves.h1.pace, secondHalfPace: an.halves.h2.pace,
              fadePct: an.halves.fade, decouplingPct: an.halves.decoupling,
              fastestMile: bm ? `${splitU} ${bm.mile} (${fmtPace(bm.sec)})` : null,
              slowestMile: wm ? `${splitU} ${wm.mile} (${fmtPace(wm.sec)})` : null,
            } : null,
            buildup: buildBuildup(r.date),
            weather: (weather[id] && typeof weather[id] === "object") ? weather[id] : null,
            notes: notes[id] || "",
          });
          const d = res && res.data;
          setInsight(p => ({ ...p, [id]: (d && (d.headline || d.buildup)) ? d : { error: "no analysis returned" } }));
        } catch (e) {
          setInsight(p => ({ ...p, [id]: { error: (e && e.message) || "generation failed" } }));
        }
      };

      // Per-mile splits from the distance/time stream.
      const milesplits = (streams) => {
        const v = (streams || []).filter(p => p.d != null && p.t != null);
        if (v.length < 12) return [];
        const out = []; let mark = MI, segT = v[0].t, hrSum = 0, hrN = 0, prev = v[0];
        for (let i = 1; i < v.length; i++) {
          const p = v[i];
          if (p.hr) { hrSum += p.hr; hrN++; }
          if ((p.d || 0) >= mark) {
            const dd = (p.d - prev.d) || 1, fr = (mark - prev.d) / dd;
            const tAt = prev.t + fr * (p.t - prev.t);
            out.push({ mile: out.length + 1, sec: tAt - segT, hr: hrN ? Math.round(hrSum / hrN) : null });
            segT = tAt; hrSum = 0; hrN = 0; mark += MI;
          }
          prev = p;
        }
        const lastD = v[v.length - 1].d, done = out.length * MI;
        if (lastD - done > MI * 0.2)
          out.push({ mile: out.length + 1, sec: v[v.length - 1].t - segT, hr: hrN ? Math.round(hrSum / hrN) : null, frac: (lastD - done) / MI });
        return out;
      };
      // First half vs second half — fade and aerobic decoupling.
      const halves = (streams, totalDist) => {
        const v = (streams || []).filter(p => p.d != null && p.t != null && p.d >= 0);
        if (v.length < 20) return null;
        let k = 0; while (k < v.length && v[k].d < totalDist / 2) k++;
        if (k < 6 || k > v.length - 6) return null;
        const seg = (a, b) => {
          const s = v.slice(a, b), dist = s[s.length - 1].d - s[0].d, time = s[s.length - 1].t - s[0].t;
          const hr = s.filter(p => p.hr).map(p => p.hr);
          const avgHr = hr.length ? hr.reduce((x, y) => x + y, 0) / hr.length : null;
          return { dist, time, avgHr, pace: dist > 0 ? time / (dist / MI) : null, ef: (avgHr && time > 0) ? (dist / time) / avgHr : null };
        };
        const h1 = seg(0, k), h2 = seg(k, v.length);
        return {
          h1, h2,
          fade: (h1.pace && h2.pace) ? (h2.pace - h1.pace) / h1.pace * 100 : null,
          decoupling: (h1.ef && h2.ef) ? (h1.ef - h2.ef) / h1.ef * 100 : null,
        };
      };
      // Splits from Strava laps — fallback when streams are unavailable.
      const lapSplits = (laps) => {
        const ls = (laps || []).filter(l => (l.distance || 0) > 200);
        if (ls.length < 3) return [];
        return ls.map((l, i) => {
          const d = l.distance || 0, t = l.moving_time || l.elapsed_time || 0;
          return { mile: i + 1, sec: d > 0 ? t / (d / MI) : 0, hr: l.average_heartrate ? Math.round(l.average_heartrate) : null };
        });
      };
      const lapHalves = (laps) => {
        const ls = (laps || []).filter(l => (l.distance || 0) > 200);
        if (ls.length < 4) return null;
        const total = ls.reduce((s, l) => s + l.distance, 0);
        let cum = 0, k = 0;
        while (k < ls.length && cum + ls[k].distance < total / 2) { cum += ls[k].distance; k++; }
        if (k < 2 || k > ls.length - 2) return null;
        const seg = (arr) => {
          const dist = arr.reduce((s, l) => s + (l.distance || 0), 0);
          const time = arr.reduce((s, l) => s + (l.moving_time || l.elapsed_time || 0), 0);
          const hrs = arr.filter(l => l.average_heartrate).map(l => l.average_heartrate);
          const avgHr = hrs.length ? hrs.reduce((x, y) => x + y, 0) / hrs.length : null;
          return { dist, time, avgHr, pace: dist > 0 ? time / (dist / MI) : null, ef: (avgHr && time > 0) ? (dist / time) / avgHr : null };
        };
        const h1 = seg(ls.slice(0, k)), h2 = seg(ls.slice(k));
        return {
          h1, h2,
          fade: (h1.pace && h2.pace) ? (h2.pace - h1.pace) / h1.pace * 100 : null,
          decoupling: (h1.ef && h2.ef) ? (h1.ef - h2.ef) / h1.ef * 100 : null,
        };
      };
      // Elevation profile from the altitude stream (metres → feet, smoothed).
      const elevation = (streams, totalDist, officialGainFt) => {
        const v = (streams || []).filter(p => p.d != null && p.alt != null);
        if (v.length < 12) return null;
        const altFt = v.map(p => p.alt * 3.281);
        const sm = altFt.map((_, i) => {
          const a = Math.max(0, i - 2), b = Math.min(altFt.length, i + 3);
          let s = 0; for (let j = a; j < b; j++) s += altFt[j];
          return s / (b - a);
        });
        let g1 = 0, g2 = 0, raw = 0;
        for (let i = 1; i < v.length; i++) {
          const d = sm[i] - sm[i - 1];
          if (d > 0) { raw += d; if (v[i].d < totalDist / 2) g1 += d; else g2 += d; }
        }
        const scale = (officialGainFt && raw > 0) ? officialGainFt / raw : 1;
        return {
          profile: v.map((p, i) => ({ mi: +(p.d / MI).toFixed(2), alt: Math.round(sm[i]) })),
          range: Math.round(Math.max(...sm) - Math.min(...sm)),
          g1: Math.round(g1 * scale), g2: Math.round(g2 * scale),
        };
      };
      const analyze = (act, detObj) => {
        const det = (detObj && typeof detObj === "object" && !detObj.error) ? detObj : null;
        const dist = act.distance || 0, time = act.moving_time || act.elapsed_time || 0;
        const streams = (det && det.streams && det.streams.length) ? det.streams : null;
        const laps = (det && det.laps && det.laps.length) ? det.laps : null;
        let splits = [], hv = null, splitSrc = null;
        if (streams) { splits = milesplits(streams); hv = halves(streams, dist); splitSrc = "mile"; }
        if (!splits.length && laps) { splits = lapSplits(laps); splitSrc = "lap"; }
        if (!hv && laps) hv = lapHalves(laps);
        return {
          dist, time, mi: dist / MI, pace: dist > 0 ? time / (dist / MI) : 0,
          vo2: (window.RaceMath && dist > 1500 && time > 300) ? window.RaceMath.estimateVO2max(dist, time) : null,
          splits, halves: hv, splitSrc,
          elev: streams ? elevation(streams, dist, act.total_elevation_gain ? act.total_elevation_gain * 3.281 : null) : null,
          hasStreams: !!streams, hasLaps: !!laps,
        };
      };
      const fadeVerdict = (f) => f == null ? null
        : f < -1.5 ? { t: "Negative split", c: C.green, d: "Ran the back half " + Math.abs(f).toFixed(1) + "% faster — excellent pacing discipline." }
        : f <= 1.5 ? { t: "Even split", c: C.green, d: "Held pace within " + Math.abs(f).toFixed(1) + "% start to finish — a textbook even effort." }
        : f <= 4 ? { t: "Slight positive split", c: C.amber, d: "Faded " + f.toFixed(1) + "% in the back half — a modest, normal late slowdown." }
        : f <= 8 ? { t: "Positive split", c: C.amber, d: "Back half " + f.toFixed(1) + "% slower — a real fade worth targeting with endurance and pacing work." }
        : { t: "Significant fade", c: C.red, d: "Back half " + f.toFixed(1) + "% slower — the wheels came off; likely went out too hard or under-fuelled." };
      const driftVerdict = (dc) => dc == null ? null
        : dc < 5 ? { t: "Aerobically coupled", c: C.green, d: "Only " + dc.toFixed(1) + "% cardiac drift — heart rate stayed locked to pace, a well-supported aerobic effort." }
        : dc < 10 ? { t: "Moderate drift", c: C.amber, d: dc.toFixed(1) + "% decoupling — some aerobic drift late, typical of a hard marathon at the edge of fitness." }
        : { t: "High drift", c: C.red, d: dc.toFixed(1) + "% decoupling — pace and HR came apart badly; a sign of heat, dehydration, or going out too hard." };
      // Marathon-calibrated: ideal racing weather is ~40-50°F with a dew
      // point under ~50°F. Warmth and humidity (via dew point) each drag
      // pace independently — score both and take the worse.
      const weatherVerdict = (w) => {
        if (!w || typeof w !== "object") return null;
        const t = (w.tMin != null && w.tMax != null) ? (w.tMin + w.tMax) / 2 : w.tStart;
        const dp = w.dp;
        const tempTier = t == null ? 0 : t <= 50 ? 0 : t <= 58 ? 1 : t <= 67 ? 2 : t <= 76 ? 3 : 4;
        const dpTier = dp == null ? 0 : dp <= 49 ? 0 : dp <= 57 ? 1 : dp <= 63 ? 2 : dp <= 69 ? 3 : 4;
        const tier = Math.max(tempTier, dpTier);
        let v;
        if (t != null && t <= 32) {
          v = { c: C.amber, t: "Cold", d: "Sub-freezing — manageable once warmed up, though layering and footing matter. Ideal marathon weather is around 40-50°F." };
        } else if (tier <= 0) {
          v = { c: C.green, t: "Ideal", d: "Cool and dry — about as good as marathon racing weather gets (ideal is ~40-50°F with a low dew point)." };
        } else if (tier === 1) {
          v = { c: C.green, t: "Good", d: "A little warmer or more humid than ideal (~40-50°F), but not enough to meaningfully cost you." };
        } else if (tier === 2) {
          v = { c: C.amber, t: "Warm — sub-optimal", d: "Warmer and more humid than ideal for a marathon — best is ~40-50°F with a low dew point. Expect a measurable drag on pace and some HR drift, especially in the back half." };
        } else if (tier === 3) {
          v = { c: C.red, t: "Hot & humid", d: "Well above ideal marathon conditions (~40-50°F) — a real pace penalty and HR drift through the back half are expected, not a fitness failure." };
        } else {
          v = { c: C.red, t: "Severe heat", d: "Oppressive heat and humidity for 26.2 miles — a large penalty is unavoidable; a slower, survival-paced day." };
        }
        if (w.wind != null && w.wind >= 14) v = { ...v, d: v.d + " Winds averaged " + Math.round(w.wind) + " mph — exposed miles would have cost you." };
        if (w.precip > 0.05) v = { ...v, d: v.d + " Around " + w.precip.toFixed(2) + " in of rain fell." };
        return v;
      };

      const Chip = ({ c, children }) => (
        <span style={{ background: c + "1f", color: c, border: "1px solid " + c + "55", borderRadius: 6, padding: "3px 9px", fontSize: 11, fontWeight: 700, whiteSpace: "nowrap" }}>{children}</span>
      );
      const Mini = ({ label, value, sub, color }) => (
        <div style={{ background: C.bg2, border: "1px solid " + C.border, borderRadius: 8, padding: "8px 10px" }}>
          <div style={{ fontSize: 9, color: C.textMuted, textTransform: "uppercase", letterSpacing: "0.05em" }}>{label}</div>
          <div style={{ fontSize: 15, fontWeight: 700, color: color || C.text, fontFamily: "'IBM Plex Mono',monospace" }}>{value}</div>
          {sub && <div style={{ fontSize: 9, color: C.textMuted, marginTop: 1 }}>{sub}</div>}
        </div>
      );
      // Dot renderer for the volume chart: a red marker on down/missed weeks,
      // a small dot on the current line, nothing on the ghost lines otherwise.
      const cmpGapDot = (key, isCur) => (props) => {
        const { cx, cy, payload } = props;
        if (cx == null || cy == null || payload[key] == null) return null;
        if (payload[key + "_gap"]) return <circle key={key + "g" + cx} cx={cx} cy={cy} r={3.6} fill={C.red} stroke={C.bg1} strokeWidth={1} />;
        return isCur ? <circle key={key + "c" + cx} cx={cx} cy={cy} r={2} fill={C.cyan} /> : null;
      };
      const SegBtn = ({ active, onClick, children }) => (
        <button onClick={onClick} style={{ padding: "4px 10px", borderRadius: 6, border: `1px solid ${active ? C.cyan : C.border}`, background: active ? C.cyan + "1f" : "transparent", color: active ? C.cyan : C.textMuted, fontSize: 11, fontWeight: 700, cursor: "pointer" }}>{children}</button>
      );
      // Jump from a build in the comparison charts to its full retrospective
      // card below ("cur" has no card — it's the in-progress block).
      const scrollToRace = (key) => {
        if (!key || key === "cur") return;
        const el = document.getElementById("race-" + key);
        if (el) { el.scrollIntoView({ behavior: "smooth", block: "start" }); el.style.transition = "box-shadow .3s"; el.style.boxShadow = `0 0 0 2px ${C.cyan}99`; setTimeout(() => { el.style.boxShadow = "none"; }, 1400); }
      };

      if (err) return <Card><EmptyState icon="⚠️" title="Couldn't load race history" sub={err} /></Card>;
      if (acts === null) return (
        <Card><div style={{ padding: "44px 20px", textAlign: "center", color: C.textMuted, fontSize: 13 }}>Loading your full race history…</div></Card>
      );

      const rows = matched.filter(m => m.act).map(m => {
        const an = analyze(m.act, detail[String(m.act.id)]);
        return { ...m, an, date: toDate(m.act.start_date) };
      }).sort((a, b) => (a.date ? a.date.getTime() : 0) - (b.date ? b.date.getTime() : 0));
      const missing = matched.filter(m => !m.act);
      const finishers = rows.filter(r => !r.entry.dnf && r.an.time > 0);
      const prTime = finishers.length ? Math.min(...finishers.map(r => r.an.time)) : null;
      const prog = finishers.map(r => ({
        label: r.entry.loc.split(",")[0].slice(0, 3) + " '" + String(r.entry.year).slice(2),
        mins: +(r.an.time / 60).toFixed(1), vo2: r.an.vo2 || null,
      }));
      const fmtClock = (min) => Math.floor(min / 60) + ":" + String(Math.round(min % 60)).padStart(2, "0");

      return (
        <div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
          <div>
            <div style={{ fontFamily: "'Space Grotesk', sans-serif", fontSize: 26, fontWeight: 800, color: C.text, marginBottom: 4 }}>Marathon Analysis</div>
            <div style={{ color: C.textMuted, fontSize: 13 }}>
              Every 26.2 — splits, pacing, and how the build has progressed
              {rows.length ? " · " + finishers.length + " finished" + (rows.length - finishers.length ? " · " + (rows.length - finishers.length) + " DNF" : "") : ""}
              {prTime ? " · PR " + fmtHMS(prTime) : ""}
            </div>
          </div>

          {buildCmp && (buildCmp.hasCur || buildCmp.priorCount > 0) && (() => {
            const tipTitle = (label) => alignMode === "start"
              ? `Build week ${label}`
              : (label >= -1 ? "Race week" : `${Math.abs(label)} weeks to race`);
            const CmpTooltip = (unit) => ({ active, payload, label }) => {
              if (!active || !payload || !payload.length) return null;
              const shown = payload.filter(p => p.value != null && !String(p.dataKey).endsWith("_gap"));
              if (!shown.length) return null;
              return (
                <div style={{ background: C.bg2, border: `1px solid ${C.border}`, borderRadius: 8, padding: "8px 10px", fontSize: 11 }}>
                  <div style={{ color: C.textDim, marginBottom: 4 }}>{tipTitle(label)}</div>
                  {shown.sort((a, b) => b.value - a.value).map(p => (
                    <div key={p.dataKey} style={{ color: p.color, display: "flex", justifyContent: "space-between", gap: 14 }}>
                      <span>{p.name}{p.payload?.[p.dataKey + "_gap"] ? " ⚠ down week" : ""}</span>
                      <span style={{ fontWeight: 700 }}>{p.value} {unit}</span>
                    </div>
                  ))}
                </div>
              );
            };
            const xAxis = (
              <XAxis dataKey="wk" type="number" domain={buildCmp.xDomain} allowDecimals={false}
                tick={{ fill: C.textMuted, fontSize: 10 }} axisLine={false} tickLine={false}
                tickFormatter={buildCmp.xFmt} />
            );
            const nowLine = buildCmp.nowX != null ? (
              <ReferenceLine x={buildCmp.nowX} stroke={C.cyan} strokeDasharray="2 2" strokeOpacity={0.5}
                label={{ value: "now", fill: C.cyan, fontSize: 9, position: "insideTopRight" }} />
            ) : null;
            return (
              <Card>
                <div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 10, flexWrap: "wrap" }}>
                  <div>
                    <div style={{ fontSize: 13, fontWeight: 600, color: C.textDim, marginBottom: 2 }}>Build Comparison{planRace?.name ? ` — toward ${planRace.name}` : ""}</div>
                    <div style={{ fontSize: 11, color: C.textMuted, marginBottom: 10, maxWidth: 620 }}>
                      Your current block (bold) over the past {Math.min(buildCmp.priorCount, 5)} marathon builds (faded ghosts; DNFs dashed).
                      {alignMode === "race"
                        ? " Aligned at the race, so tapers and peaks line up. "
                        : " Aligned at each build's start, so the ramps line up. "}
                      Red dots flag down / missed weeks once a build was under way.
                    </div>
                  </div>
                  <div style={{ display: "flex", gap: 6 }}>
                    <SegBtn active={alignMode === "race"} onClick={() => setAlignMode("race")}>To race</SegBtn>
                    <SegBtn active={alignMode === "start"} onClick={() => setAlignMode("start")}>To build start</SegBtn>
                  </div>
                </div>

                <div style={{ fontSize: 11, color: C.textMuted, margin: "4px 0 2px" }}>Weekly volume</div>
                <ResponsiveContainer width="100%" height={250}>
                  <LineChart data={buildCmp.volData} margin={{ top: 6, right: 14, left: 0, bottom: 0 }}>
                    <CartesianGrid strokeDasharray="3 3" stroke={C.border} vertical={false} />
                    {xAxis}
                    <YAxis tick={{ fill: C.textMuted, fontSize: 10 }} axisLine={false} tickLine={false} width={40}
                      domain={[0, "auto"]} label={{ value: "mi / wk", angle: -90, position: "insideLeft", fill: C.textMuted, fontSize: 10 }} />
                    <Tooltip content={CmpTooltip("mi")} />
                    <Legend wrapperStyle={{ fontSize: 10, color: C.textMuted }} />
                    {buildCmp.series.map(s => (
                      <Line key={s.key} type="monotone" dataKey={s.key} name={s.name}
                        stroke={s.color} strokeWidth={s.isCur ? 3 : 1.5} strokeOpacity={s.isCur ? 1 : 0.45}
                        strokeDasharray={s.dnf ? "2 4" : undefined}
                        dot={cmpGapDot(s.key, s.isCur)} activeDot={{ r: 4, onClick: () => scrollToRace(s.key) }}
                        onClick={() => scrollToRace(s.key)}
                        connectNulls={!s.isCur} isAnimationActive={false} />
                    ))}
                    {nowLine}
                  </LineChart>
                </ResponsiveContainer>

                <div style={{ fontSize: 11, color: C.textMuted, margin: "10px 0 2px" }}>Long run progression — the strongest marathon predictor</div>
                <ResponsiveContainer width="100%" height={200}>
                  <LineChart data={buildCmp.lrData} margin={{ top: 6, right: 14, left: 0, bottom: 0 }}>
                    <CartesianGrid strokeDasharray="3 3" stroke={C.border} vertical={false} />
                    {xAxis}
                    <YAxis tick={{ fill: C.textMuted, fontSize: 10 }} axisLine={false} tickLine={false} width={40}
                      domain={[0, "auto"]} label={{ value: "long (mi)", angle: -90, position: "insideLeft", fill: C.textMuted, fontSize: 10 }} />
                    <Tooltip content={CmpTooltip("mi")} />
                    {buildCmp.series.map(s => (
                      <Line key={s.key} type="monotone" dataKey={s.key} name={s.name}
                        stroke={s.color} strokeWidth={s.isCur ? 3 : 1.5} strokeOpacity={s.isCur ? 1 : 0.45}
                        strokeDasharray={s.dnf ? "2 4" : undefined}
                        dot={s.isCur ? { r: 2, fill: C.cyan } : false} activeDot={{ r: 4, onClick: () => scrollToRace(s.key) }}
                        onClick={() => scrollToRace(s.key)}
                        connectNulls={!s.isCur} isAnimationActive={false} />
                    ))}
                    <ReferenceLine y={18} stroke={C.textMuted} strokeDasharray="2 4" strokeOpacity={0.4}
                      label={{ value: "18mi", fill: C.textMuted, fontSize: 9, position: "insideRight" }} />
                    {nowLine}
                  </LineChart>
                </ResponsiveContainer>

                <div style={{ fontSize: 11, color: C.textMuted, margin: "10px 0 2px" }}>Aerobic efficiency — meters per heartbeat on easy/aerobic runs (higher = same pace at lower HR)</div>
                <ResponsiveContainer width="100%" height={200}>
                  <LineChart data={buildCmp.effData} margin={{ top: 6, right: 14, left: 0, bottom: 0 }}>
                    <CartesianGrid strokeDasharray="3 3" stroke={C.border} vertical={false} />
                    {xAxis}
                    <YAxis tick={{ fill: C.textMuted, fontSize: 10 }} axisLine={false} tickLine={false} width={40}
                      domain={["auto", "auto"]} tickFormatter={v => v.toFixed(2)}
                      label={{ value: "m / beat", angle: -90, position: "insideLeft", fill: C.textMuted, fontSize: 10 }} />
                    <Tooltip content={CmpTooltip("m/beat")} />
                    {buildCmp.series.map(s => (
                      <Line key={s.key} type="monotone" dataKey={s.key} name={s.name}
                        stroke={s.color} strokeWidth={s.isCur ? 3 : 1.5} strokeOpacity={s.isCur ? 1 : 0.45}
                        strokeDasharray={s.dnf ? "2 4" : undefined}
                        dot={s.isCur ? { r: 2, fill: C.cyan } : false} activeDot={{ r: 4, onClick: () => scrollToRace(s.key) }}
                        onClick={() => scrollToRace(s.key)}
                        connectNulls={!s.isCur} isAnimationActive={false} />
                    ))}
                    {nowLine}
                  </LineChart>
                </ResponsiveContainer>

                {/* At-a-glance consistency per build — click a past build to open its retrospective */}
                <div style={{ fontSize: 10, color: C.textMuted, margin: "12px 0 6px" }}>Tap a past build (chip or line) to open its full retrospective ↓</div>
                <div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
                  {buildCmp.stats.map(st => (
                    <div key={st.key} onClick={() => scrollToRace(st.key)} title={st.isCur ? "" : "Open " + st.name + " analysis"}
                      style={{ flex: "1 1 150px", minWidth: 140, background: C.bg2, border: `1px solid ${st.isCur ? C.cyan + "66" : C.border}`, borderRadius: 8, padding: "8px 10px", cursor: st.isCur ? "default" : "pointer" }}>
                      <div style={{ display: "flex", alignItems: "center", gap: 6, marginBottom: 4 }}>
                        <span style={{ width: 9, height: 9, borderRadius: 5, background: st.color, display: "inline-block", opacity: st.isCur ? 1 : 0.6 }} />
                        <span style={{ fontSize: 11, fontWeight: 700, color: C.text }}>{st.name}</span>
                        {!st.isCur && <span style={{ fontSize: 10, color: C.cyan, marginLeft: "auto" }}>View →</span>}
                      </div>
                      <div style={{ fontSize: 10, color: C.textMuted, lineHeight: 1.5 }}>
                        Peak <strong style={{ color: C.text }}>{Math.round(st.peak)}mi</strong> · {st.buildLen}wk build<br />
                        <strong style={{ color: st.longRunWeeks >= 4 ? C.green : C.textDim }}>{st.longRunWeeks}</strong> wk with 18+ long ·{" "}
                        <strong style={{ color: st.downWeeks === 0 ? C.green : st.downWeeks <= 2 ? C.amber : C.red }}>{st.downWeeks}</strong> down wk
                      </div>
                    </div>
                  ))}
                </div>

                {!buildCmp.hasCur && (
                  <div style={{ fontSize: 10, color: C.textMuted, marginTop: 8 }}>No active plan with a race date — showing past builds only. Set an active plan to overlay your current block.</div>
                )}
              </Card>
            );
          })()}

          {finishers.length >= 2 && (
            <Card>
              <div style={{ fontSize: 13, fontWeight: 600, color: C.textDim, marginBottom: 2 }}>Marathon Progression</div>
              <div style={{ fontSize: 11, color: C.textMuted, marginBottom: 12 }}>Finish time (lower is faster) and the VO₂max each performance implies, across every completed marathon.</div>
              <ResponsiveContainer width="100%" height={220}>
                <ComposedChart data={prog} margin={{ top: 6, right: 8, left: 0, bottom: 0 }}>
                  <CartesianGrid strokeDasharray="3 3" stroke={C.border} vertical={false} />
                  <XAxis dataKey="label" tick={{ fill: C.textMuted, fontSize: 11 }} axisLine={false} tickLine={false} />
                  <YAxis yAxisId="t" tick={{ fill: C.textMuted, fontSize: 10 }} axisLine={false} tickLine={false}
                    domain={["auto", "auto"]} tickFormatter={fmtClock} width={44} />
                  <YAxis yAxisId="v" orientation="right" tick={{ fill: C.textMuted, fontSize: 10 }} axisLine={false} tickLine={false}
                    domain={["auto", "auto"]} width={32} />
                  <Tooltip content={({ active, payload }) => {
                    if (!active || !payload || !payload.length) return null;
                    const p = payload[0].payload;
                    return (
                      <div style={{ background: C.bg1, border: "1px solid " + C.border, borderRadius: 8, padding: "8px 10px", fontSize: 11 }}>
                        <div style={{ color: C.text, fontWeight: 700, marginBottom: 3 }}>{p.label}</div>
                        <div style={{ color: C.cyan }}>Finish {fmtHMS(p.mins * 60)}</div>
                        {p.vo2 && <div style={{ color: C.amber }}>VO₂ ≈ {p.vo2}</div>}
                      </div>
                    );
                  }} />
                  <Line yAxisId="t" type="monotone" dataKey="mins" stroke={C.cyan} strokeWidth={2.5} dot={{ r: 4, fill: C.cyan }} name="Finish" />
                  <Line yAxisId="v" type="monotone" dataKey="vo2" stroke={C.amber} strokeWidth={2} strokeDasharray="5 4" dot={{ r: 3, fill: C.amber }} name="VO₂" connectNulls />
                </ComposedChart>
              </ResponsiveContainer>
              <div style={{ overflowX: "auto", marginTop: 12 }}>
                <table style={{ width: "100%", borderCollapse: "collapse", fontSize: 12 }}>
                  <thead>
                    <tr style={{ color: C.textMuted, textAlign: "left" }}>
                      {["Race", "Date", "Result", "Pace/mi", "Avg HR", "VO₂"].map((h, i) => (
                        <th key={h} style={{ padding: "6px 8px", borderBottom: "1px solid " + C.border, fontWeight: 600, textAlign: i > 1 ? "right" : "left" }}>{h}</th>
                      ))}
                    </tr>
                  </thead>
                  <tbody>
                    {rows.map(r => {
                      const isPR = !r.entry.dnf && r.an.time === prTime;
                      return (
                        <tr key={r.entry.key} style={{ color: C.textDim }}>
                          <td style={{ padding: "6px 8px", borderBottom: "1px solid " + C.border, color: C.text }}>
                            {r.entry.name} '{String(r.entry.year).slice(2)}
                            {isPR && <span style={{ color: C.green, fontWeight: 700 }}> · PR</span>}
                          </td>
                          <td style={{ padding: "6px 8px", borderBottom: "1px solid " + C.border }}>{r.date ? r.date.toLocaleDateString("en-US", { month: "short", year: "numeric" }) : "—"}</td>
                          <td style={{ padding: "6px 8px", borderBottom: "1px solid " + C.border, textAlign: "right", fontFamily: "'IBM Plex Mono',monospace", color: r.entry.dnf ? C.red : C.text }}>
                            {r.entry.dnf ? "DNF" : fmtHMS(r.an.time)}
                          </td>
                          <td style={{ padding: "6px 8px", borderBottom: "1px solid " + C.border, textAlign: "right", fontFamily: "'IBM Plex Mono',monospace" }}>{fmtPace(r.an.pace)}</td>
                          <td style={{ padding: "6px 8px", borderBottom: "1px solid " + C.border, textAlign: "right", fontFamily: "'IBM Plex Mono',monospace" }}>{r.an.dist && r.act.average_heartrate ? Math.round(r.act.average_heartrate) : "—"}</td>
                          <td style={{ padding: "6px 8px", borderBottom: "1px solid " + C.border, textAlign: "right", fontFamily: "'IBM Plex Mono',monospace" }}>{r.an.vo2 || "—"}</td>
                        </tr>
                      );
                    })}
                  </tbody>
                </table>
              </div>
              {finishers.length >= 2 && (() => {
                const first = finishers[0], last = finishers[finishers.length - 1];
                const d = first.an.time - last.an.time;
                return (
                  <div style={{ fontSize: 12, color: C.textMuted, marginTop: 10, lineHeight: 1.6 }}>
                    From {first.entry.name} '{String(first.entry.year).slice(2)} to {last.entry.name} '{String(last.entry.year).slice(2)} your finish time
                    {d > 0 ? <span style={{ color: C.green }}> dropped {fmtHMS(Math.abs(d))}</span>
                           : d < 0 ? <span style={{ color: C.amber }}> rose {fmtHMS(Math.abs(d))}</span>
                           : " held steady"} — a {fmtPace(Math.abs(first.an.pace - last.an.pace))}/mi pace {d >= 0 ? "gain" : "regression"}.
                  </div>
                );
              })()}
            </Card>
          )}

          {[...rows].reverse().map(r => {
            const a = r.act, an = r.an, det = detail[String(a.id)], w = weather[String(a.id)], ins = insight[String(a.id)];
            const fv = an.halves ? fadeVerdict(an.halves.fade) : null;
            const dv = an.halves ? driftVerdict(an.halves.decoupling) : null;
            const wv = (w && typeof w === "object") ? weatherVerdict(w) : null;
            const fullSplits = an.splits.filter(s => !s.frac);
            const medSec = median(fullSplits.map(s => s.sec));
            const best = fullSplits.length ? fullSplits.reduce((x, y) => y.sec < x.sec ? y : x) : null;
            const worst = fullSplits.length ? fullSplits.reduce((x, y) => y.sec > x.sec ? y : x) : null;
            const elevFt = a.total_elevation_gain ? Math.round(a.total_elevation_gain * 3.281) : null;
            const cad = a.average_cadence ? Math.round(a.average_cadence > 120 ? a.average_cadence : a.average_cadence * 2) : null;
            const isPR = !r.entry.dnf && an.time === prTime;
            const splitUnit = an.splitSrc === "lap" ? "lap" : "mile";
            const btnSty = { background: C.cyan + "1f", border: "1px solid " + C.cyan + "55", color: C.cyan, borderRadius: 6, padding: "4px 11px", fontSize: 11, fontWeight: 700, cursor: "pointer", fontFamily: "'IBM Plex Mono',monospace" };
            return (
              <Card key={r.entry.key} id={"race-" + r.entry.key}>
                <div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 10, flexWrap: "wrap" }}>
                  <div>
                    <div style={{ fontFamily: "'Space Grotesk',sans-serif", fontSize: 19, fontWeight: 800, color: C.text }}>
                      {r.entry.name} {r.entry.year}
                      {isPR && <span style={{ fontSize: 11, color: C.green, fontWeight: 700, marginLeft: 8 }}>★ PR</span>}
                    </div>
                    <div style={{ fontSize: 12, color: C.textMuted, marginTop: 2 }}>
                      {r.entry.loc} · {r.date ? r.date.toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" }) : "date unknown"}
                    </div>
                  </div>
                  <div style={{ textAlign: "right" }}>
                    {r.entry.dnf ? (
                      <>
                        <div style={{ fontSize: 22, fontWeight: 800, color: C.red, fontFamily: "'IBM Plex Mono',monospace" }}>DNF</div>
                        <div style={{ fontSize: 11, color: C.textMuted }}>reached {an.mi.toFixed(1)} mi ({Math.round(an.mi / 26.2188 * 100)}%) in {fmtHMS(an.time)}</div>
                      </>
                    ) : (
                      <>
                        <div style={{ fontSize: 26, fontWeight: 800, color: C.cyan, fontFamily: "'IBM Plex Mono',monospace" }}>{fmtHMS(an.time)}</div>
                        <div style={{ fontSize: 11, color: C.textMuted }}>{fmtPace(an.pace)}/mi · {an.mi.toFixed(1)} mi</div>
                      </>
                    )}
                  </div>
                </div>

                <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit,minmax(88px,1fr))", gap: 8, marginTop: 14 }}>
                  <Mini label="Distance" value={an.mi.toFixed(2)} sub="mi" />
                  <Mini label="Avg pace" value={fmtPace(an.pace)} sub="/mi" />
                  <Mini label="Avg HR" value={a.average_heartrate ? Math.round(a.average_heartrate) : "—"} sub="bpm" />
                  <Mini label="Max HR" value={a.max_heartrate ? Math.round(a.max_heartrate) : "—"} sub="bpm" />
                  <Mini label="Elev gain" value={elevFt != null ? elevFt : "—"} sub="ft" />
                  <Mini label="Cadence" value={cad || "—"} sub="spm" />
                  <Mini label="VO₂ est" value={an.vo2 || "—"} color={C.amber} sub="ml/kg/min" />
                  <Mini label="Calories" value={a.calories ? Math.round(a.calories) : "—"} sub="kcal" />
                </div>

                {w === "loading" && <div style={{ marginTop: 12, fontSize: 11, color: C.textMuted }}>Loading race-day weather…</div>}
                {w === "error" && <div style={{ marginTop: 12, fontSize: 11, color: C.textMuted }}>Race-day weather couldn't be retrieved for this race.</div>}
                {wv && (
                  <div style={{ marginTop: 14 }}>
                    <div style={{ display: "flex", gap: 7, flexWrap: "wrap", alignItems: "center", marginBottom: 6 }}>
                      <div style={{ fontSize: 11, color: C.textDim, fontWeight: 600 }}>Race-day weather</div>
                      <Chip c={wv.c}>{wv.t}</Chip>
                    </div>
                    <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit,minmax(78px,1fr))", gap: 8 }}>
                      <Mini label="Temp" value={Math.round(w.tStart) + "–" + Math.round(w.tMax)} sub="°F start→peak" />
                      <Mini label="Humidity" value={w.hum != null ? w.hum + "%" : "—"} />
                      <Mini label="Dew point" value={w.dp != null ? Math.round(w.dp) : "—"} sub="°F" />
                      <Mini label="Wind" value={w.wind != null ? Math.round(w.wind) : "—"} sub="mph avg" />
                      <Mini label="Rain" value={w.precip > 0.005 ? w.precip.toFixed(2) : "0"} sub="in" />
                    </div>
                    <div style={{ fontSize: 12, color: C.textMuted, marginTop: 8, lineHeight: 1.6 }}>{wv.d}</div>
                  </div>
                )}

                {an.halves && (
                  <div style={{ marginTop: 14 }}>
                    <div style={{ display: "flex", gap: 7, flexWrap: "wrap", marginBottom: 8 }}>
                      {fv && <Chip c={fv.c}>{fv.t}</Chip>}
                      {dv && <Chip c={dv.c}>{dv.t}</Chip>}
                    </div>
                    <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit,minmax(120px,1fr))", gap: 8 }}>
                      <Mini label="1st half" value={fmtPace(an.halves.h1.pace)} sub={"/mi" + (an.halves.h1.avgHr ? " · " + Math.round(an.halves.h1.avgHr) + " bpm" : "")} />
                      <Mini label="2nd half" value={fmtPace(an.halves.h2.pace)} sub={"/mi" + (an.halves.h2.avgHr ? " · " + Math.round(an.halves.h2.avgHr) + " bpm" : "")} />
                    </div>
                    <div style={{ fontSize: 12, color: C.textMuted, marginTop: 8, lineHeight: 1.6 }}>
                      {fv && <div>{fv.d}</div>}
                      {dv && <div>{dv.d}</div>}
                    </div>
                  </div>
                )}

                {an.elev && an.elev.profile.length > 5 && (
                  <div style={{ marginTop: 14 }}>
                    <div style={{ fontSize: 11, color: C.textDim, fontWeight: 600, marginBottom: 6 }}>Course profile — elevation</div>
                    <ResponsiveContainer width="100%" height={120}>
                      <AreaChart data={an.elev.profile} margin={{ top: 4, right: 4, left: 0, bottom: 0 }}>
                        <defs>
                          <linearGradient id={"elg" + r.entry.key} x1="0" y1="0" x2="0" y2="1">
                            <stop offset="5%" stopColor={C.purple} stopOpacity={0.35} />
                            <stop offset="95%" stopColor={C.purple} stopOpacity={0.03} />
                          </linearGradient>
                        </defs>
                        <CartesianGrid strokeDasharray="3 3" stroke={C.border} vertical={false} />
                        <XAxis dataKey="mi" tick={{ fill: C.textMuted, fontSize: 9 }} axisLine={false} tickLine={false}
                          tickFormatter={v => Math.round(v)} interval={Math.max(1, Math.floor(an.elev.profile.length / 8))} />
                        <YAxis tick={{ fill: C.textMuted, fontSize: 9 }} axisLine={false} tickLine={false} width={40}
                          tickFormatter={v => v + "ft"} domain={["auto", "auto"]} />
                        <Tooltip content={({ active, payload }) => {
                          if (!active || !payload || !payload.length) return null;
                          const p = payload[0].payload;
                          return (
                            <div style={{ background: C.bg1, border: "1px solid " + C.border, borderRadius: 8, padding: "6px 9px", fontSize: 11 }}>
                              <div style={{ color: C.text }}>Mile {p.mi.toFixed(1)}</div>
                              <div style={{ color: C.purple }}>{p.alt} ft</div>
                            </div>
                          );
                        }} />
                        <Area type="monotone" dataKey="alt" stroke={C.purple} strokeWidth={1.5} fill={"url(#elg" + r.entry.key + ")"} isAnimationActive={false} />
                      </AreaChart>
                    </ResponsiveContainer>
                    <div style={{ fontSize: 11, color: C.textMuted, marginTop: 4, lineHeight: 1.6 }}>
                      {(elevFt != null ? elevFt + " ft climbed" : "elevation n/a")} · {an.elev.range} ft range · back half climbed {an.elev.g2} ft vs {an.elev.g1} ft up front
                      {an.elev.g2 > an.elev.g1 * 1.3 ? " — a tougher second half that adds to any fade."
                        : an.elev.g1 > an.elev.g2 * 1.3 ? " — front-loaded climbing, kinder finish."
                        : " — climbing evenly distributed."}
                    </div>
                  </div>
                )}

                {fullSplits.length >= 4 && (
                  <div style={{ marginTop: 14 }}>
                    <div style={{ fontSize: 11, color: C.textDim, fontWeight: 600, marginBottom: 6 }}>
                      {an.splitSrc === "lap" ? "Lap" : "Mile"} splits — pace (bars) and heart rate (line)
                    </div>
                    <ResponsiveContainer width="100%" height={170}>
                      <ComposedChart data={fullSplits.map(s => ({ mile: s.mile, pace: Math.round(s.sec), hr: s.hr }))} margin={{ top: 4, right: 4, left: 0, bottom: 0 }}>
                        <CartesianGrid strokeDasharray="3 3" stroke={C.border} vertical={false} />
                        <XAxis dataKey="mile" tick={{ fill: C.textMuted, fontSize: 9 }} axisLine={false} tickLine={false} />
                        <YAxis yAxisId="p" tick={{ fill: C.textMuted, fontSize: 9 }} axisLine={false} tickLine={false}
                          domain={["dataMin", "dataMax"]} tickFormatter={fmtPace} width={40} />
                        <YAxis yAxisId="h" orientation="right" tick={{ fill: C.textMuted, fontSize: 9 }} axisLine={false} tickLine={false}
                          domain={["auto", "auto"]} width={30} />
                        <Tooltip content={({ active, payload }) => {
                          if (!active || !payload || !payload.length) return null;
                          const p = payload[0].payload;
                          return (
                            <div style={{ background: C.bg1, border: "1px solid " + C.border, borderRadius: 8, padding: "7px 9px", fontSize: 11 }}>
                              <div style={{ color: C.text, fontWeight: 700 }}>{an.splitSrc === "lap" ? "Lap" : "Mile"} {p.mile}</div>
                              <div style={{ color: C.cyan }}>{fmtPace(p.pace)}/mi</div>
                              {p.hr && <div style={{ color: C.red }}>{p.hr} bpm</div>}
                            </div>
                          );
                        }} />
                        {medSec > 0 && <ReferenceLine yAxisId="p" y={medSec} stroke={C.textMuted} strokeDasharray="4 4" />}
                        <Bar yAxisId="p" dataKey="pace" radius={[2, 2, 0, 0]} isAnimationActive={false}>
                          {fullSplits.map((s, i) => (
                            <Cell key={i} fill={s.sec < medSec * 0.98 ? C.green : s.sec > medSec * 1.04 ? C.red : C.cyan} />
                          ))}
                        </Bar>
                        <Line yAxisId="h" type="monotone" dataKey="hr" stroke={C.amber} strokeWidth={1.5} dot={false} connectNulls isAnimationActive={false} />
                      </ComposedChart>
                    </ResponsiveContainer>
                    {best && worst && (
                      <div style={{ fontSize: 11, color: C.textMuted, marginTop: 4 }}>
                        Fastest {splitUnit} {best.mile} ({fmtPace(best.sec)}) · slowest {splitUnit} {worst.mile} ({fmtPace(worst.sec)}) · {fmtPace(worst.sec - best.sec)} spread
                      </div>
                    )}
                  </div>
                )}

                {(det === undefined || det === "loading") && (
                  <div style={{ marginTop: 14, fontSize: 12, color: C.textMuted }}>Loading splits &amp; elevation from Strava…</div>
                )}
                {det && typeof det === "object" && det.error && (
                  <div style={{ marginTop: 14, padding: "11px 13px", background: C.bg2, border: "1px dashed " + C.border, borderRadius: 8, fontSize: 12, color: C.textMuted }}>
                    Couldn't load Strava detail — {det.error}.{" "}
                    {isOwner && <button onClick={() => retryDetail(a.id)} style={btnSty}>Retry</button>}
                  </div>
                )}
                {det && typeof det === "object" && !det.error && !an.hasStreams && !an.hasLaps && (
                  <div style={{ marginTop: 14, padding: "11px 13px", background: C.bg2, border: "1px dashed " + C.border, borderRadius: 8, fontSize: 12, color: C.textMuted }}>
                    Strava has no lap or stream detail stored for this race{r.entry.year < 2025 ? " — not unusual for older activities" : ""}.{" "}
                    {isOwner && <button onClick={() => retryDetail(a.id)} style={btnSty}>Retry fetch</button>}
                  </div>
                )}

                {(() => {
                  const bu = buildBuildup(r.date);
                  if (!bu.some(wk => wk.runs > 0)) return null;
                  const total = bu.reduce((s, wk) => s + wk.miles, 0);
                  const peak = Math.max.apply(null, bu.map(wk => wk.miles));
                  const longest = Math.max.apply(null, bu.map(wk => wk.longest));
                  return (
                    <div style={{ marginTop: 14, borderTop: "1px solid " + C.border, paddingTop: 12 }}>
                      <div style={{ fontSize: 11, color: C.textDim, fontWeight: 600, marginBottom: 6 }}>20-week build-up — weekly volume &amp; long run</div>
                      <ResponsiveContainer width="100%" height={140}>
                        <ComposedChart data={bu} margin={{ top: 4, right: 4, left: 0, bottom: 0 }}>
                          <CartesianGrid strokeDasharray="3 3" stroke={C.border} vertical={false} />
                          <XAxis dataKey="wk" tick={{ fill: C.textMuted, fontSize: 9 }} axisLine={false} tickLine={false} />
                          <YAxis yAxisId="m" tick={{ fill: C.textMuted, fontSize: 9 }} axisLine={false} tickLine={false} width={32} />
                          <YAxis yAxisId="l" orientation="right" tick={{ fill: C.textMuted, fontSize: 9 }} axisLine={false} tickLine={false} width={28} />
                          <Tooltip content={({ active, payload }) => {
                            if (!active || !payload || !payload.length) return null;
                            const p = payload[0].payload;
                            return (
                              <div style={{ background: C.bg1, border: "1px solid " + C.border, borderRadius: 8, padding: "7px 9px", fontSize: 11 }}>
                                <div style={{ color: C.text, fontWeight: 700 }}>{Math.abs(p.wk)} wk to race</div>
                                <div style={{ color: C.cyan }}>{p.miles} mi · {p.runs} runs</div>
                                <div style={{ color: C.amber }}>long run {p.longest} mi</div>
                                {p.quality > 0 && <div style={{ color: C.green }}>{p.quality} quality</div>}
                              </div>
                            );
                          }} />
                          <Bar yAxisId="m" dataKey="miles" fill={C.cyan} radius={[2, 2, 0, 0]} isAnimationActive={false} />
                          <Line yAxisId="l" type="monotone" dataKey="longest" stroke={C.amber} strokeWidth={1.5} dot={false} isAnimationActive={false} />
                        </ComposedChart>
                      </ResponsiveContainer>
                      <div style={{ fontSize: 11, color: C.textMuted, marginTop: 4 }}>
                        {Math.round(total)} mi over 20 weeks · peak {Math.round(peak)} mi/wk · longest run {longest.toFixed(1)} mi
                      </div>
                    </div>
                  );
                })()}

                <div style={{ marginTop: 14, borderTop: "1px solid " + C.border, paddingTop: 12 }}>
                  <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 8, marginBottom: 8, flexWrap: "wrap" }}>
                    <div style={{ fontSize: 11, color: C.textDim, fontWeight: 600 }}>Race analysis</div>
                    {isOwner && ins !== "loading" && (
                      <button onClick={() => genAnalysis(r)} style={btnSty}>{ins && (ins.headline || ins.buildup) ? "Regenerate" : "Generate"}</button>
                    )}
                  </div>
                  <div style={{ marginBottom: 10 }}>
                    <div style={{ fontSize: 10, color: C.textMuted, marginBottom: 3 }}>
                      Your notes — course, fuelling, GI issues, wind, how it felt. The analysis weighs these heavily.
                    </div>
                    {isOwner ? (
                      <textarea
                        value={notes[String(a.id)] || ""}
                        onChange={e => { const t = e.target.value; setNotes(p => ({ ...p, [String(a.id)]: t })); }}
                        onBlur={e => saveNote(a.id, e.target.value)}
                        placeholder="e.g. Headwind miles 7-19; stomach trouble late; very exposed course…"
                        rows={2}
                        style={{ width: "100%", boxSizing: "border-box", resize: "vertical", background: C.bg2,
                          border: "1px solid " + C.border, borderRadius: 6, padding: "7px 9px", color: C.text,
                          fontSize: 12, fontFamily: "'IBM Plex Mono',monospace", lineHeight: 1.5 }} />
                    ) : (notes[String(a.id)] ? (
                      <div style={{ fontSize: 12, color: C.textDim, fontStyle: "italic" }}>“{notes[String(a.id)]}”</div>
                    ) : null)}
                  </div>
                  {ins === "loading" && <div style={{ fontSize: 12, color: C.textMuted }}>Analysing the 20-week build and race execution… about 20s.</div>}
                  {ins && ins.error && <div style={{ fontSize: 12, color: C.red }}>Couldn't generate analysis — {ins.error}</div>}
                  {ins && (ins.headline || ins.buildup) && (
                    <div style={{ fontSize: 12, color: C.textDim, lineHeight: 1.65, display: "flex", flexDirection: "column", gap: 10 }}>
                      <div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}>
                        {ins.grade && <Chip c={C.cyan}>Grade {ins.grade}</Chip>}
                        {ins.headline && <span style={{ color: C.text, fontWeight: 600 }}>{ins.headline}</span>}
                      </div>
                      {ins.buildup && <div><div style={{ color: C.cyan, fontWeight: 600, marginBottom: 2 }}>The build-up</div>{ins.buildup}</div>}
                      {ins.execution && <div><div style={{ color: C.cyan, fontWeight: 600, marginBottom: 2 }}>Race execution</div>{ins.execution}</div>}
                      {ins.verdict && <div><div style={{ color: C.cyan, fontWeight: 600, marginBottom: 2 }}>Verdict</div>{ins.verdict}</div>}
                      {Array.isArray(ins.takeaways) && ins.takeaways.length > 0 && (
                        <div>
                          <div style={{ color: C.amber, fontWeight: 600, marginBottom: 3 }}>For the next build</div>
                          {ins.takeaways.map((t, i) => <div key={i}>· {t}</div>)}
                        </div>
                      )}
                    </div>
                  )}
                  {(!ins || (ins !== "loading" && !(ins.headline || ins.buildup) && !ins.error)) && (
                    <div style={{ fontSize: 12, color: C.textMuted }}>
                      {isOwner ? "Generate an in-depth, race-specific analysis — how the 20-week build-up and your execution shaped this result, and what to change next time."
                               : "No race analysis yet."}
                    </div>
                  )}
                </div>

                <div style={{ fontSize: 10, color: C.textMuted, marginTop: 10 }}>Matched to Strava activity: “{a.name || "untitled"}”</div>
              </Card>
            );
          })}

          {missing.length > 0 && (
            <Card>
              <div style={{ fontSize: 13, fontWeight: 600, color: C.textDim, marginBottom: 6 }}>Not found in synced history</div>
              <div style={{ fontSize: 12, color: C.textMuted, lineHeight: 1.7 }}>
                {missing.map(m => <div key={m.entry.key}>· {m.entry.name} {m.entry.year} — no matching {m.entry.year} run in your Strava history.</div>)}
              </div>
            </Card>
          )}
          {rows.length === 0 && missing.length === MARATHON_REGISTRY.length && (
            <EmptyState icon="🏃" title="No marathons matched" sub="None of the five races could be matched to a synced Strava activity." />
          )}
        </div>
      );
    }

window.AppViews = Object.assign(window.AppViews || {}, { MarathonAnalysis });
