// running/src/races.jsx — slice 9 of the module split.
//
// RACES & GOALS view plus RaceCourseProfileCard (course profile +
// per-mile pacing strategy + weather adjuster — Races is its only
// render site today, so it travels with this slice and stays exported
// on window.AppViews for any future use). Extracted verbatim from
// running/index.html. Race-prediction math destructures from the
// window.RaceMath UMD lib; getHRZones from window.HRZones (lib).

const { C, Card, Btn, EmptyState, RaceLogo, fmt } = window.SharedUI;
const useData = () => (window.__appUseData ? window.__appUseData() : null);
const { getHRZones } = window.HRZones;
const { calcEffectiveVO2max, predictRaceTimes, classifyRunIntent, bestRecentQualityEffort } = window.RaceMath;
const { useState, useEffect } = React;
const {
  Area, Bar, ComposedChart, Line, Scatter,
  XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
  ReferenceLine,
} = Recharts;

    // Race Course Profile + Per-Mile Pacing Strategy + Weather adjuster.
    // Self-contained: takes a race ({name, location, goalTime}) and looks
    // it up in the curated raceCourses catalog. Returns null if the race
    // doesn't match a catalog entry. Used in Summary (active plan's race)
    // and Races (per upcoming race) so the same elevation chart and pace
    // grid show up wherever a race lives.
    function RaceCourseProfileCard({ raceName, raceLocation, goalTime }) {
      const [wxTempF, setWxTempF] = useState(52);
      const [wxHumidity, setWxHumidity] = useState(50);
      const [wxWind, setWxWind] = useState(5);
      const [wxForecast, setWxForecast] = useState(null);
      const [wxLoading, setWxLoading] = useState(false);
      const [goalTimeOverride, setGoalTimeOverride] = useState(null);
      const [gpsOvershoot, setGpsOvershoot] = useState(false);

      if (!raceName) return null;
      const fakeRace = { name: raceName || "", location: raceLocation || "" };
      const course = window.RaceCourses?.matchCourse?.(fakeRace);
      if (!course) return null;
      const profile = window.RaceCourses.cumulativeProfile(course);
      const chartData = profile.map(p => ({
        mile: p.mile,
        netElevFt: p.netElevFt,
        cumGainFt: p.cumGainFt,
        gainThisMileFt: p.elevGainFt,
        gradePct: p.gradeAvgPct,
        notes: p.notes,
      }));
      const difficultyColor = course.difficulty === "easy" ? C.green : course.difficulty === "moderate" ? C.amber : C.red;

      // Per-mile pacing helpers (only run when goalTime is provided).
      const parseGoalSec = (s) => {
        if (!s) return null;
        const cleaned = String(s).replace(/^sub[- ]?/i, "");
        const parts = cleaned.split(":").map(p => parseInt(p, 10));
        if (parts.some(p => !Number.isFinite(p))) return null;
        if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2];
        if (parts.length === 2) return parts[0] * 3600 + parts[1] * 60;
        return null;
      };
      const fmtGoalHMS = (sec) => {
        if (!Number.isFinite(sec) || sec <= 0) return "";
        const h = Math.floor(sec / 3600);
        const m = Math.floor((sec % 3600) / 60);
        const s = sec % 60;
        return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
      };
      const planGoalSec = parseGoalSec(goalTime);
      const overrideGoalSec = goalTimeOverride != null ? parseGoalSec(goalTimeOverride) : null;
      const goalSec = overrideGoalSec || planGoalSec;
      const GPS_OVERSHOOT_RATIO = 1.012;
      const baseDistMi = course.distanceMi || 26.2;
      const targetDistMi = gpsOvershoot ? baseDistMi * GPS_OVERSHOOT_RATIO : baseDistMi;

      const wxConditions = window.WeatherPacing.adjustForConditions({
        tempF: wxTempF, humidityPct: wxHumidity, windMph: wxWind, precipMM: 0,
      });
      const strategy = goalSec ? window.PacingStrategy.generatePacingStrategy({
        goalTimeSec: goalSec, distanceMi: targetDistMi, course,
      }) : null;
      const adjusted = strategy ? window.WeatherPacing.applyToStrategy(strategy, wxConditions.totalSec) : null;
      const wxBadgeColor = wxConditions.risk === "ideal" ? C.green
        : wxConditions.risk === "warm" ? C.amber
        : wxConditions.risk === "hot" || wxConditions.risk === "extreme" ? C.red
        : wxConditions.risk === "cold" ? C.cyan
        : C.textMuted;
      const phaseColor = (p) => p === "earlyConservation" ? C.green
        : p === "finishPush" ? C.red
        : C.cyan;
      const paceColor = (pace) => {
        if (!strategy) return C.text;
        const delta = pace - strategy.basePace;
        if (delta > 15) return C.red;
        if (delta > 5) return C.amber;
        if (delta < -10) return C.cyan;
        return C.text;
      };
      const fmt = (s) => window.PacingStrategy.fmtPace(s);

      return (
        <Card>
          <div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", flexWrap: "wrap", gap: 8, marginBottom: 8 }}>
            <div>
              <div style={{ fontSize: 13, fontWeight: 600, color: C.textDim }}>
                Race Course Profile · {course.shortName}
                <span style={{ marginLeft: 8, fontSize: 9, padding: "2px 6px", borderRadius: 4, background: difficultyColor + "20", color: difficultyColor, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.05em" }}>
                  {course.difficulty}
                </span>
              </div>
              <div style={{ fontSize: 10, color: C.textMuted, marginTop: 2 }}>
                {course.terrain} · +{course.totalElevationGainFt}ft / -{course.totalElevationLossFt}ft net
              </div>
            </div>
          </div>

          <div style={{ padding: "10px 14px", background: C.bg2, borderRadius: 8, marginBottom: 12, fontSize: 11, color: C.textDim, lineHeight: 1.5 }}>
            {course.summary}
          </div>

          <ResponsiveContainer width="100%" height={260}>
            <ComposedChart data={chartData} margin={{ top: 8, right: 16, left: 0, bottom: 4 }}>
              <CartesianGrid strokeDasharray="3 3" stroke={C.border} vertical={false} />
              <XAxis dataKey="mile" tick={{ fill: C.textMuted, fontSize: 10 }} axisLine={false} tickLine={false}
                label={{ value: "mile", fill: C.textMuted, fontSize: 10, position: "insideBottom", offset: -2 }} />
              <YAxis yAxisId="net" tick={{ fill: C.textMuted, fontSize: 10 }} axisLine={false} tickLine={false}
                tickFormatter={v => v + "ft"} label={{ value: "net elev", angle: -90, position: "insideLeft", fill: C.textMuted, fontSize: 10 }} />
              <YAxis yAxisId="grade" orientation="right" tick={{ fill: C.textMuted, fontSize: 10 }} axisLine={false} tickLine={false}
                tickFormatter={v => v + "%"} hide />
              <Tooltip contentStyle={{ background: C.bg2, border: `1px solid ${C.border}`, borderRadius: 8, fontSize: 11, color: C.text, maxWidth: 280 }}
                content={({ active, payload, label }) => {
                  if (!active || !payload?.length) return null;
                  const p = payload[0].payload;
                  return (
                    <div style={{ background: C.bg2, border: `1px solid ${C.border}`, borderRadius: 8, padding: "8px 12px", fontSize: 11, color: C.text, maxWidth: 280, lineHeight: 1.4 }}>
                      <div style={{ fontWeight: 700, marginBottom: 4 }}>
                        Mile {label}
                        {p.gradePct ? <span style={{ color: C.textMuted, fontWeight: 400 }}> · {p.gradePct >= 0 ? "+" : ""}{p.gradePct}% grade</span> : null}
                      </div>
                      <div style={{ color: C.amber, fontFamily: "'IBM Plex Mono',monospace" }}>
                        +{Math.round(p.gainThisMileFt)} ft this mile · {Math.round(p.netElevFt)} ft net
                      </div>
                      {p.notes && <div style={{ marginTop: 4, color: C.textDim, fontStyle: "italic" }}>{p.notes}</div>}
                    </div>
                  );
                }} />
              <Legend wrapperStyle={{ fontSize: 10, color: C.textMuted }} />
              <Area yAxisId="net" type="monotone" dataKey="netElevFt" stroke={C.cyan} fill={C.cyan + "30"} strokeWidth={2} name="Net elev" />
              <Bar yAxisId="net" dataKey="gainThisMileFt" fill={C.amber + "70"} name="Mile gain" maxBarSize={10} />
              {(course.notableLandmarks || []).map((lm, i) => (
                <ReferenceLine key={i} x={Math.round(lm.mile)} stroke={C.textMuted} strokeDasharray="3 3" yAxisId="net"
                  label={{ value: lm.label, position: "top", fill: C.textMuted, fontSize: 9 }} />
              ))}
            </ComposedChart>
          </ResponsiveContainer>

          <div style={{ marginTop: 8, fontSize: 10, color: C.textMuted, fontStyle: "italic", lineHeight: 1.5 }}>
            Cyan area = net elevation vs. start. Amber bars = climb in that mile. Hover any mile for tactical notes.
          </div>

          {strategy && adjusted && (
            <div style={{ marginTop: 16 }}>
              <div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", marginBottom: 8, flexWrap: "wrap", gap: 8 }}>
                <div style={{ fontSize: 12, fontWeight: 600, color: C.textDim }}>
                  Per-Mile Pacing Strategy
                  <span style={{ fontSize: 10, color: C.textMuted, marginLeft: 8, fontWeight: 400 }}>
                    Goal {fmtGoalHMS(goalSec)}{overrideGoalSec ? " (custom)" : ""} · base {fmt(strategy.basePace)}/mi · totals to {window.PacingStrategy.fmtTime(adjusted.totalSec)}
                    {gpsOvershoot && (
                      <span> · GPS {targetDistMi.toFixed(2)}mi</span>
                    )}
                    {wxConditions.totalSec !== 0 && (
                      <span> · weather +{wxConditions.totalSec}s/mi</span>
                    )}
                  </span>
                </div>
              </div>
              <div style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 10, flexWrap: "wrap", fontSize: 11, color: C.textMuted }}>
                <label style={{ display: "flex", alignItems: "center", gap: 6 }}>
                  <span>Target time</span>
                  <input
                    type="text"
                    placeholder={goalTime || "3:00:00"}
                    value={goalTimeOverride ?? ""}
                    onChange={e => setGoalTimeOverride(e.target.value || null)}
                    style={{
                      width: 80, padding: "3px 6px", background: C.bg2, border: `1px solid ${C.border}`,
                      borderRadius: 4, color: C.text, fontFamily: "'IBM Plex Mono',monospace", fontSize: 11,
                    }}
                  />
                  {goalTimeOverride && (
                    <button onClick={() => setGoalTimeOverride(null)}
                      style={{ background: "none", border: "none", color: C.textMuted, fontSize: 10, cursor: "pointer", padding: 0 }}>
                      reset
                    </button>
                  )}
                </label>
                <label style={{ display: "flex", alignItems: "center", gap: 6, cursor: "pointer" }}>
                  <input type="checkbox" checked={gpsOvershoot} onChange={e => setGpsOvershoot(e.target.checked)} />
                  <span>Pace for GPS distance ({(baseDistMi * GPS_OVERSHOOT_RATIO).toFixed(2)}mi)</span>
                </label>
              </div>
              <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(70px, 1fr))", gap: 4 }}>
                {adjusted.miles.filter(m => Number.isInteger(m.mile)).map(m => (
                  <div key={m.mile} title={m.note || ""} style={{
                    padding: "6px 4px", background: C.bg2, borderRadius: 4,
                    borderLeft: `2px solid ${phaseColor(m.phase)}`, textAlign: "center",
                    cursor: m.note ? "help" : "default",
                  }}>
                    <div style={{ fontSize: 9, color: C.textMuted, fontWeight: 600 }}>Mi {m.mile}</div>
                    <div style={{ fontSize: 13, fontWeight: 700, color: paceColor(m.targetPace), fontFamily: "'IBM Plex Mono',monospace" }}>
                      {fmt(m.targetPace)}
                    </div>
                    {m.gradePct !== 0 && (
                      <div style={{ fontSize: 8, color: m.gradePct > 0 ? C.amber : C.cyan, marginTop: 1 }}>
                        {m.gradePct > 0 ? "+" : ""}{m.gradePct}%
                      </div>
                    )}
                  </div>
                ))}
              </div>
              <div style={{ marginTop: 8, display: "flex", gap: 14, fontSize: 9, color: C.textMuted, flexWrap: "wrap" }}>
                <span><span style={{ display: "inline-block", width: 8, height: 8, background: C.green, marginRight: 4, verticalAlign: "middle" }}></span>Controlled start (Mi 1-2)</span>
                <span><span style={{ display: "inline-block", width: 8, height: 8, background: C.cyan, marginRight: 4, verticalAlign: "middle" }}></span>Steady (target pace)</span>
                <span><span style={{ display: "inline-block", width: 8, height: 8, background: C.red, marginRight: 4, verticalAlign: "middle" }}></span>Finish push (final 5K)</span>
                <span style={{ marginLeft: "auto" }}>Hover a mile for tactical note.</span>

                <div style={{ width: "100%", marginTop: 14, padding: "12px 14px", background: C.bg2, borderRadius: 8 }}>
                  <div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", marginBottom: 8, flexWrap: "wrap", gap: 8 }}>
                    <div style={{ fontSize: 12, fontWeight: 600, color: C.textDim }}>
                      Weather-Adjusted Pacing
                      <span style={{ fontSize: 10, color: C.textMuted, fontWeight: 400, marginLeft: 8 }}>
                        Slide to model race-day conditions
                      </span>
                    </div>
                    <div style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 11, fontFamily: "'IBM Plex Mono',monospace" }}>
                      <span style={{ padding: "2px 8px", borderRadius: 4, background: wxBadgeColor + "20", color: wxBadgeColor, fontWeight: 700, textTransform: "uppercase", fontSize: 9, letterSpacing: "0.05em" }}>
                        {wxConditions.risk}
                      </span>
                      <span style={{ color: wxConditions.totalSec > 0 ? C.amber : wxConditions.totalSec < 0 ? C.cyan : C.textMuted, fontWeight: 700 }}>
                        {wxConditions.totalSec > 0 ? "+" : ""}{wxConditions.totalSec}s/mi
                      </span>
                      <button onClick={async () => {
                        setWxLoading(true);
                        try {
                          const fn = functions.httpsCallable("getWeather");
                          const loc = course.shortName === "NYC" ? "New York,NY"
                            : course.shortName === "Boston" ? "Boston,MA"
                            : course.shortName === "Chicago" ? "Chicago,IL"
                            : course.shortName === "London" ? "London,UK"
                            : course.shortName === "Berlin" ? "Berlin,DE"
                            : course.shortName === "Tokyo" ? "Tokyo,JP"
                            : raceLocation || "Philadelphia,PA";
                          const res = await fn({ location: loc });
                          const w = res.data?.current;
                          if (w) {
                            setWxTempF(w.tempF);
                            setWxHumidity(w.humidity);
                            setWxWind(w.windMph || 0);
                            setWxForecast({ ...w, location: loc });
                          }
                        } catch (e) { console.warn("[wx] forecast failed:", e.message); }
                        setWxLoading(false);
                      }} disabled={wxLoading}
                        style={{ padding: "3px 8px", background: "none", border: `1px solid ${C.border}`, borderRadius: 4, color: C.textMuted, fontSize: 10, cursor: wxLoading ? "wait" : "pointer" }}>
                        {wxLoading ? "…" : "↻ Forecast"}
                      </button>
                    </div>
                  </div>
                  <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(160px, 1fr))", gap: 12 }}>
                    <div>
                      <div style={{ fontSize: 10, color: C.textDim, display: "flex", justifyContent: "space-between" }}>
                        <span>Temperature</span>
                        <span style={{ color: C.text, fontFamily: "'IBM Plex Mono',monospace" }}>{wxTempF}°F</span>
                      </div>
                      <input type="range" min="32" max="95" step="1" value={wxTempF}
                        onChange={e => setWxTempF(parseInt(e.target.value, 10))}
                        style={{ width: "100%", accentColor: wxBadgeColor }} />
                    </div>
                    <div>
                      <div style={{ fontSize: 10, color: C.textDim, display: "flex", justifyContent: "space-between" }}>
                        <span>Humidity</span>
                        <span style={{ color: C.text, fontFamily: "'IBM Plex Mono',monospace" }}>{wxHumidity}%</span>
                      </div>
                      <input type="range" min="20" max="100" step="5" value={wxHumidity}
                        onChange={e => setWxHumidity(parseInt(e.target.value, 10))}
                        style={{ width: "100%", accentColor: wxBadgeColor }} />
                    </div>
                    <div>
                      <div style={{ fontSize: 10, color: C.textDim, display: "flex", justifyContent: "space-between" }}>
                        <span>Wind</span>
                        <span style={{ color: C.text, fontFamily: "'IBM Plex Mono',monospace" }}>{wxWind} mph</span>
                      </div>
                      <input type="range" min="0" max="30" step="1" value={wxWind}
                        onChange={e => setWxWind(parseInt(e.target.value, 10))}
                        style={{ width: "100%", accentColor: wxBadgeColor }} />
                    </div>
                  </div>
                  <div style={{ marginTop: 8, fontSize: 11, color: C.textDim, lineHeight: 1.5 }}>
                    {wxConditions.advice}
                  </div>
                  {wxForecast && (
                    <div style={{ marginTop: 6, fontSize: 10, color: C.textMuted, fontStyle: "italic" }}>
                      Pulled from forecast for {wxForecast.location} — note: forecasts only reach 3 days out, useful in race week.
                    </div>
                  )}
                </div>
              </div>
            </div>
          )}
        </Card>
      );
    }

    // ── Race Fueling card — the gut-training ledger + race-day fuel plan
    // + carb-load protocol for an upcoming HM/marathon. All rules come
    // from the mirrored RaceFueling lib; the gut ledger prefers the
    // snapshot (racePrep.gutTraining), recomputing client-side through
    // the same lib when the snapshot hasn't caught up.
    // ── Goal Evidence card — demonstrated readiness gates (banked, not
    // projected) for an upcoming goal race. Snapshot-first
    // (racePrep.goalEvidence); client fallback recomputes through the
    // same mirrored GoalEvidence lib (default Riegel exponent — the
    // snapshot's calibrated one wins once it recomputes).
    function GoalEvidenceCard({ race, goalTime, activities }) {
      const { trainingSnapshot } = useData() || {};
      const GE = window.GoalEvidence;
      if (!GE || !race || !goalTime) return null;
      const raceMs = race.date ? Date.parse(race.date) : NaN;
      if (!Number.isFinite(raceMs) || raceMs < Date.now() - 86400000) return null;
      const snapGe = trainingSnapshot?.racePrep?.goalEvidence;
      const ge = (snapGe && snapGe.goalTimeSec === GE.parseGoalSec(goalTime))
        ? snapGe
        : GE.goalEvidenceLedger({ activities: activities || [], goalTime, raceDistance: race.distance || "marathon", raceMath: window.RaceMath });
      if (!ge) return null;
      const pct = ge.gateCount ? ge.metCount / ge.gateCount : 0;
      const headColor = pct >= 1 ? C.green : pct >= 0.5 ? C.amber : C.red;
      return (
        <Card style={{ marginTop: 12 }}>
          <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 4 }}>
            <div style={{ fontSize: 13, fontWeight: 600, color: C.textDim }}>Goal Evidence — {race.name}</div>
            <span style={{ fontSize: 10, fontWeight: 700, padding: "3px 10px", borderRadius: 5, background: headColor + "18", color: headColor, fontFamily: "'IBM Plex Mono',monospace" }}>
              {ge.metCount}/{ge.gateCount} gates banked
            </span>
          </div>
          <div style={{ fontSize: 10, color: C.textMuted, marginBottom: 12, fontStyle: "italic", lineHeight: 1.5 }}>
            Projections say what the model believes; gates say what you've demonstrated in the last {ge.windowDays / 7} weeks. A goal with unbanked gates is a hope, not a plan.
          </div>
          <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
            {ge.gates.map((g) => (
              <div key={g.id} style={{ display: "flex", alignItems: "baseline", gap: 10, padding: "7px 10px", background: C.bg2, borderRadius: 8, opacity: g.met ? 0.85 : 1 }}>
                <span style={{ flexShrink: 0, width: 16, textAlign: "center", color: g.met ? C.green : C.red, fontWeight: 900, fontSize: 12 }}>{g.met ? "✓" : "✗"}</span>
                <div style={{ flex: 1, minWidth: 0 }}>
                  <div style={{ fontSize: 12, fontWeight: 600, color: g.met ? C.textDim : C.text }}>{g.label}</div>
                  <div style={{ fontSize: 10, color: C.textMuted, lineHeight: 1.4 }}>{g.detail}</div>
                </div>
                <div style={{ flexShrink: 0, textAlign: "right", fontFamily: "'IBM Plex Mono',monospace" }}>
                  <div style={{ fontSize: 12, fontWeight: 700, color: g.met ? C.green : C.amber }}>{g.achieved}</div>
                  <div style={{ fontSize: 9, color: C.textMuted }}>{g.target}</div>
                </div>
              </div>
            ))}
          </div>
          {Array.isArray(ge.requiredBenchmarks) && ge.requiredBenchmarks.length > 0 && (
            <div style={{ marginTop: 10, fontSize: 10, color: C.textMuted, fontFamily: "'IBM Plex Mono',monospace" }}>
              Equivalent benchmarks for {goalTime}: {ge.requiredBenchmarks.map(b => `${b.name} ${b.required}`).join(" · ")}
            </div>
          )}
        </Card>
      );
    }

    function RaceFuelingCard({ race, goalTime, activities }) {
      const { trainingSnapshot, healthEntries } = useData() || {};
      const RF = window.RaceFueling;
      if (!RF || !race) return null;
      const distM = ({ "5k": 5000, "10k": 10000, "half": 21097, "half marathon": 21097, "marathon": 42195 })[String(race.distance || "marathon").toLowerCase()] || 42195;
      if (distM < 21000) return null; // in-race fueling plans matter HM+
      const raceMs = race.date ? Date.parse(race.date) : NaN;
      if (!Number.isFinite(raceMs) || raceMs < Date.now() - 86400000) return null;
      // goal time → seconds ("2:59:00", "sub-3:00", "3:05")
      const goalSec = (() => {
        if (!goalTime) return null;
        const p = String(goalTime).replace(/^sub[- ]?/i, "").split(":").map(x => parseInt(x, 10));
        if (p.some(x => !Number.isFinite(x))) return null;
        if (p.length === 3) return p[0] * 3600 + p[1] * 60 + p[2];
        if (p.length === 2) return p[0] * 3600 + p[1] * 60;
        return null;
      })();
      const gut = trainingSnapshot?.racePrep?.gutTraining
        || RF.gutTrainingLedger(activities || [], {});
      const gutRows = gut.recent || gut.rows || [];
      const latestWeightLb = (() => {
        const w = (healthEntries || []).filter(h => h.weight).slice(-1)[0];
        return w ? w.weight : null;
      })();
      const weightKg = latestWeightLb ? latestWeightLb * 0.453592 : null;
      const fuel = goalSec ? RF.raceFuelPlan({
        goalTimeSec: goalSec, distMeters: distM,
        gutReadiness: gut.readiness, bestCarbsPerHour: gut.bestCarbsPerHour,
      }) : null;
      const load = weightKg ? RF.carbLoadPlan({ raceDateIso: race.date, weightKg }) : null;
      const readyColor = gut.readiness === "race-ready" ? C.green : gut.readiness === "developing" ? C.amber : C.red;
      const fmtMin = (m) => m < 0 ? `${m}min` : `${Math.floor(m / 60)}:${String(m % 60).padStart(2, "0")}`;
      return (
        <Card style={{ marginTop: 12 }}>
          <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 4 }}>
            <div style={{ fontSize: 13, fontWeight: 600, color: C.textDim }}>Race Fueling — {race.name}</div>
            <span style={{ fontSize: 10, fontWeight: 700, padding: "3px 10px", borderRadius: 5, background: readyColor + "18", color: readyColor, textTransform: "uppercase", letterSpacing: "0.05em" }}>
              gut: {gut.readiness}
            </span>
          </div>
          <div style={{ fontSize: 10, color: C.textMuted, marginBottom: 12, fontStyle: "italic", lineHeight: 1.5 }}>
            Race-day absorption is trained, not hoped for. The plan below is gated by what your gut has demonstrated on logged long runs — log fueling in the Training log's Athlete Log card to unlock higher rates.
          </div>

          {/* Gut-training ledger */}
          <div style={{ padding: "10px 12px", background: C.bg2, borderRadius: 8, marginBottom: 10 }}>
            <div style={{ fontSize: 10, fontWeight: 700, color: C.cyan, textTransform: "uppercase", letterSpacing: "0.05em", marginBottom: 6 }}>
              Gut training — last {gut.windowDays || 70} days
            </div>
            <div style={{ display: "flex", gap: 16, flexWrap: "wrap", fontSize: 11, fontFamily: "'IBM Plex Mono',monospace", marginBottom: gutRows.length ? 8 : 0 }}>
              <span style={{ color: C.text }}>{gut.fueledLongRuns}/{gut.longRuns} long runs fueled</span>
              {gut.bestCarbsPerHour != null && <span style={{ color: C.green }}>best {gut.bestCarbsPerHour} g/h</span>}
              <span style={{ color: gut.giIssueRuns ? C.amber : C.textMuted }}>{gut.giIssueRuns ? `GI issues ×${gut.giIssueRuns}` : "no GI issues logged"}</span>
              {gut.longRuns - gut.loggedLongRuns > 0 && <span style={{ color: C.textMuted }}>{gut.longRuns - gut.loggedLongRuns} unlogged</span>}
            </div>
            {gutRows.slice(0, 5).map((r, i) => (
              <div key={i} style={{ display: "flex", gap: 10, fontSize: 10, color: C.textMuted, fontFamily: "'IBM Plex Mono',monospace", padding: "2px 0" }}>
                <span style={{ width: 74 }}>{r.date}</span>
                <span style={{ width: 50, textAlign: "right" }}>{r.mi}mi</span>
                <span style={{ color: r.fueled ? C.green : r.fasted ? C.amber : C.textMuted }}>
                  {r.fueled ? (r.carbsPerHour != null ? `${r.carbsPerHour} g/h` : "fueled") : r.fasted ? "fasted" : r.logged ? "no fuel noted" : "not logged"}
                </span>
                {r.giIssues && <span style={{ color: C.amber }}>GI ⚠</span>}
              </div>
            ))}
          </div>

          {/* Race-day plan */}
          {fuel && (
            <div style={{ padding: "10px 12px", background: C.bg2, borderRadius: 8, marginBottom: 10 }}>
              <div style={{ fontSize: 10, fontWeight: 700, color: C.green, textTransform: "uppercase", letterSpacing: "0.05em", marginBottom: 6 }}>
                Race day — {fuel.targetCarbsPerHour} g/h target · {fuel.gels} gels · {fuel.fluids.mlPerHour}ml/h fluids · {fuel.fluids.sodiumMgPerHour}mg/h sodium
              </div>
              <div style={{ display: "flex", flexWrap: "wrap", gap: 6, marginBottom: 8 }}>
                {fuel.events.map((e, i) => (
                  <span key={i} style={{ fontSize: 10, fontFamily: "'IBM Plex Mono',monospace", padding: "3px 8px", borderRadius: 5, background: C.bg1, border: `1px solid ${C.border}`, color: C.textDim }}>
                    {e.atMin < 0 ? "−15min" : `${fmtMin(e.atMin)} · mi ${e.atMile}`}
                  </span>
                ))}
              </div>
              {fuel.notes.map((n, i) => (
                <div key={i} style={{ fontSize: 10, color: C.textMuted, lineHeight: 1.5, marginBottom: 2 }}>• {n}</div>
              ))}
            </div>
          )}
          {!fuel && (
            <div style={{ fontSize: 11, color: C.textMuted, marginBottom: 10 }}>Set a goal time on this race to generate the per-gel race plan.</div>
          )}

          {/* Carb-load protocol */}
          {load && (
            <div style={{ padding: "10px 12px", background: C.bg2, borderRadius: 8 }}>
              <div style={{ fontSize: 10, fontWeight: 700, color: C.amber, textTransform: "uppercase", letterSpacing: "0.05em", marginBottom: 6 }}>
                Carb load — final 3 days{latestWeightLb ? ` (at ${Math.round(latestWeightLb)} lb)` : ""}
              </div>
              {load.days.map((d, i) => (
                <div key={i} style={{ display: "flex", gap: 10, fontSize: 11, padding: "3px 0", fontFamily: "'IBM Plex Mono',monospace" }}>
                  <span style={{ width: 78, color: C.textMuted }}>{d.date.slice(5)} (−{d.daysOut}d)</span>
                  <span style={{ width: 110, color: C.text }}>{d.gramsTarget}g ({d.gPerKg} g/kg)</span>
                  <span style={{ flex: 1, color: C.textMuted, fontFamily: "inherit", fontSize: 10, lineHeight: 1.5 }}>{d.note}</span>
                </div>
              ))}
              <div style={{ display: "flex", gap: 10, fontSize: 11, padding: "3px 0", fontFamily: "'IBM Plex Mono',monospace", borderTop: `1px solid ${C.border}`, marginTop: 4, paddingTop: 7 }}>
                <span style={{ width: 78, color: C.textMuted }}>race AM</span>
                <span style={{ width: 110, color: C.text }}>{load.morning.gramsTarget}g</span>
                <span style={{ flex: 1, color: C.textMuted, fontFamily: "inherit", fontSize: 10, lineHeight: 1.5 }}>{load.morning.note}</span>
              </div>
            </div>
          )}
          {!load && (
            <div style={{ fontSize: 11, color: C.textMuted }}>Carb-load targets need a recent weight reading (Health sync).</div>
          )}
        </Card>
      );
    }

    function Races({ userId, activities, isOwner, hrZoneConfig }) {
      const [races, setRaces] = useState([]);
      const [showAdd, setShowAdd] = useState(false);
      const [form, setForm] = useState({ name:"", date:"", distance:"marathon", goalTime:"", targetPace:"", notes:"" });
      const [editingRace, setEditingRace] = useState(null); // race id being edited
      const [editForm, setEditForm] = useState({ name:"", date:"", distance:"marathon", goalTime:"", targetPace:"", notes:"" });
      const [qualityDetail, setQualityDetail] = useState({}); // { activityId: {laps,streams}|null } — detail docs for work-pace analysis
      const { activePlan, healthEntries } = useData();

      // Quality-session detection — shared by the lap prefetch below and
      // the Pace Progress chart. Layered so warmup + recovery-jog dilution
      // of the whole-run averages doesn't bury genuine tempo / interval
      // workouts (the reason the chart kept reading "not enough sessions").
      const QUALITY_PLAN_TYPES = { tempo:"Tempo", intervals:"Intervals", interval:"Intervals", threshold:"Threshold", race_pace:"Race Pace", race:"Race" };
      const planQualityTypeById = {};
      (activePlan?.weeks || []).forEach(w => (w.days || []).forEach(d => {
        if (d.linkedActivityId && d.type) planQualityTypeById[String(d.linkedActivityId)] = (d.type || "").toLowerCase();
      }));
      // Two return shapes: tight match (plan-tagged or Strava-tagged or hard
      // intent) means the chart should keep the dot even when work isolation
      // fails and falls back to whole-run pace; loose match (max-speed gap or
      // a linked "long" with possible MP segments) means the dot only renders
      // if an analyzer actually isolates work — otherwise the whole-run avg
      // misrepresents the session (a 5:40 rep set lands at 9:00+ once the
      // jog rest is mixed in).
      const qualityRunType = (r, obsMaxHR) => {
        const pt = planQualityTypeById[String(r.id)];
        if (pt && QUALITY_PLAN_TYPES[pt]) return { label: QUALITY_PLAN_TYPES[pt], loose: false };
        if (r.workout_type === 1) return { label: "Race", loose: false };
        if (r.workout_type === 3) return { label: "Quality", loose: false };
        const intent = classifyRunIntent(r);
        if (intent === "race") return { label: "Race", loose: false };
        if (intent === "hard") return { label: "Quality", loose: false };
        if (intent !== "easy" && obsMaxHR > 0 && r.max_heartrate && r.max_heartrate >= 0.92 * obsMaxHR) return { label: "Quality", loose: false };
        // Named stride / pickup sessions. The athlete tags these in the
        // activity name, so trust the name even when the run reads "easy"
        // overall (strides tacked onto a shakeout) or carries no max_speed
        // (treadmill) — both cases the max-speed gap below never catches.
        // Loose: the analyzers still have to isolate the burst, so a run
        // merely *mentioning* strides with no fast segment drops out.
        if (/\bstrides?\b|\bpick.?ups?\b/.test(String(r.name || "").toLowerCase())) return { label: "Strides", loose: true };
        // Loose detection — needs work isolation to render.
        if (pt === "long" && r.distance > 9000) return { label: "Long-Quality", loose: true };
        // Top-speed vs average gap. Strava's max_speed is a 1s peak so it
        // false-positives on GPS glitches; a ≥20% gap on a run that's also
        // ≥2 mi long filters most of those while still surfacing genuine
        // pace surges (strides, MP segments, accidentally hard finishes).
        const avg = r.average_speed || 0, mx = r.max_speed || 0;
        if (avg > 0 && mx > 0 && mx >= avg * 1.20 && r.distance >= 3200) return { label: "Quality", loose: true };
        return null;
      };

      // Prefetch cached laps + streams for every quality session in the
      // chart window so the work-rep pace (work/rest split) can replace the
      // jog-diluted whole-run average on the Pace Progress chart.
      useEffect(() => {
        if (!userId) return;
        const acts = (activities || []).filter(a => a.type === "Run" || a.type === "VirtualRun");
        const obsMaxHR = Math.max(150, ...acts.map(a => a.max_heartrate || 0));
        const cutoff = Date.now() - 16 * 7 * 86400000;
        const ids = new Set();
        acts.forEach(a => {
          if (!a.id) return;
          const rd = a.start_date?.toDate ? a.start_date.toDate() : new Date(a.start_date);
          if (!(rd.getTime() >= cutoff)) return;
          if (qualityRunType(a, obsMaxHR)) ids.add(String(a.id));
          // Long runs always get a detail prefetch — even if qualityRunType
          // returns null we still want to peek at the stream to detect any
          // embedded MP/HMP segment that would upgrade the dot.
          if (a.distance > 14000) ids.add(String(a.id));
        });
        ids.forEach(id => {
          if (qualityDetail[id] !== undefined) return;
          setQualityDetail(prev => prev[id] !== undefined ? prev : { ...prev, [id]: null });
          db.collection("users").doc(userId).collection("activities").doc(id)
            .collection("detail").doc("laps").get()
            .then(async doc => {
              const d = doc.exists ? (doc.data() || {}) : {};
              const haveStreams = Array.isArray(d.streams) && d.streams.length > 0;
              const haveLaps = Array.isArray(d.laps) && d.laps.length > 0;
              // No cached streams/laps → ask the cloud function to pull from
              // Strava. Without this the chart silently uses whole-run avg
              // (warmup + work + recovery diluted together), which is what
              // made a 4×10min @ 6:59 session read as 8:15/mi.
              if (!haveStreams && !haveLaps) {
                try {
                  const fn = functions.httpsCallable("fetchActivityDetail");
                  const res = await fn({ activityId: id });
                  const r = res?.data || {};
                  setQualityDetail(prev => ({ ...prev, [id]: {
                    laps: Array.isArray(r.laps) ? r.laps : [],
                    streams: Array.isArray(r.streams) ? r.streams : [],
                  } }));
                  return;
                } catch (_) {
                  // Fall through to whatever (empty) cache had.
                }
              }
              setQualityDetail(prev => ({ ...prev, [id]: {
                laps: Array.isArray(d.laps) ? d.laps : [],
                streams: Array.isArray(d.streams) ? d.streams : [],
              } }));
            })
            .catch(() => setQualityDetail(prev => ({ ...prev, [id]: { laps: [], streams: [] } })));
        });
      }, [userId, activities, activePlan]);

      // The active training plan is the single source of truth for the
      // goal of its target race; a races-collection entry for the same
      // race only mirrors it for display/pacing here.
      const isPlanRace = (r) =>
        activePlan?.raceName && r?.name &&
        String(r.name).trim().toLowerCase() === String(activePlan.raceName).trim().toLowerCase();
      const effGoal = (r) =>
        (isPlanRace(r) && activePlan?.goalTime) ? activePlan.goalTime : (r?.goalTime || "");

      useEffect(() => {
        if (!userId) return;
        const unsub = db.collection("users").doc(userId).collection("races")
          .orderBy("date","asc").onSnapshot(snap => {
            setRaces(snap.docs.map(d => ({ id:d.id, ...d.data() })));
          });
        return unsub;
      }, [userId]);

      const addRace = async () => {
        if (!form.name || !form.date) return;
        await db.collection("users").doc(userId).collection("races").add({
          ...form, createdAt: firebase.firestore.FieldValue.serverTimestamp()
        });
        setForm({ name:"", date:"", distance:"marathon", goalTime:"", targetPace:"", notes:"" });
        setShowAdd(false);
      };

      const startEdit = (race) => {
        setEditingRace(race.id);
        setEditForm({
          name: race.name||"", date: race.date||"", distance: race.distance||"marathon",
          goalTime: race.goalTime||"", targetPace: race.targetPace||"",
          actualTime: race.actualTime||"", notes: race.notes||"",
        });
      };

      const updateRace = async () => {
        if (!editingRace || !editForm.name || !editForm.date) return;
        await db.collection("users").doc(userId).collection("races").doc(editingRace).update(editForm);
        setEditingRace(null);
      };

      const deleteRace = async (raceId) => {
        if (!confirm("Delete this race?")) return;
        await db.collection("users").doc(userId).collection("races").doc(raceId).delete();
        if (editingRace === raceId) setEditingRace(null);
      };

      // Blended race time projections (Daniels VDOT + Riegel) — Garmin
      // VO2max is the canonical source (matches Status panel + Analytics
      // Race Calc); calc-from-runs is only the fallback when no Garmin
      // reading exists.
      const runs = activities.filter(a=>a.type==="Run"||a.type==="VirtualRun").sort((a,b)=>b.start_date?.seconds-a.start_date?.seconds);
      // Garmin VO2max is noisy day-to-day (45↔56 depending on whether the
      // last run was a Z1 treadmill jog or a quality effort). Anchoring
      // the projection on the single latest reading let one base run
      // swing "realistic" by 30min. Use the median of recent readings —
      // robust to the swing, still tracks genuine fitness change.
      const recentGarminVO2 = (healthEntries || [])
        .filter(h => h?.garminVO2max > 30 && h.date)
        .sort((a, b) => (b.date || "").localeCompare(a.date || ""))
        .slice(0, 21)
        .map(h => h.garminVO2max)
        .sort((a, b) => a - b);
      const robustGarminVO2 = recentGarminVO2.length
        ? recentGarminVO2[Math.floor(recentGarminVO2.length / 2)]
        : undefined;
      const effectiveVO2 = robustGarminVO2 || calcEffectiveVO2max(runs, 90);
      const projections = predictRaceTimes(effectiveVO2, runs);

      const upcomingRaces = races.filter(r => new Date(r.date) >= new Date());
      const pastRaces = races.filter(r => new Date(r.date) < new Date());

      return (
        <div style={{display:"flex",flexDirection:"column",gap:20}}>
          <div style={{display:"flex",justifyContent:"space-between",alignItems:"center"}}>
            <div>
              <div style={{fontFamily:"'Space Grotesk', sans-serif",fontSize:26,fontWeight:800,color:C.text,marginBottom:4}}>Races & Goals</div>
              <div style={{color:C.textMuted,fontSize:13}}>{upcomingRaces.length} upcoming · {pastRaces.length} completed</div>
            </div>
            {isOwner && <Btn onClick={() => setShowAdd(!showAdd)}>+ Add Race</Btn>}
          </div>

          {/* Long Run Progress + Pace Progress Charts */}
          {(() => {
            const now = new Date();
            const weeks = 16;
            // Determine target long run distance from nearest upcoming race (or default marathon)
            const nextRace = upcomingRaces[0];
            const distMap = { "5K":5000, "10K":10000, "half":21097, "marathon":42195, "ultra":80000 };
            const raceDistM = nextRace ? (distMap[nextRace.distance] || 42195) : 42195;
            const raceDistMi = raceDistM / 1609.34;
            // Fully trained long run targets by race distance (% of race distance, capped)
            const longRunTargetMi = raceDistMi <= 6.2 ? raceDistMi : // 5K-10K: full distance
              raceDistMi <= 13.2 ? Math.min(raceDistMi * 0.85, 12) : // half: ~11mi
              Math.min(raceDistMi * 0.77, 22); // marathon: ~20mi
            const raceName = nextRace ? `${nextRace.distance} (${nextRace.name})` : "Marathon";

            // Build weekly data (most recent week first, then reverse for chart)
            const weeklyData = [];
            for (let w = weeks - 1; w >= 0; w--) {
              const wEnd = new Date(now); wEnd.setDate(now.getDate() - w * 7); wEnd.setHours(23,59,59,999);
              const wStart = new Date(wEnd); wStart.setDate(wEnd.getDate() - 6); wStart.setHours(0,0,0,0);
              const wRuns = runs.filter(r => {
                const rd = r.start_date?.toDate ? r.start_date.toDate() : new Date(r.start_date);
                return rd >= wStart && rd <= wEnd;
              });
              const longRunMi = wRuns.length > 0 ? Math.max(...wRuns.map(r => r.distance / 1609.34)) : 0;
              const label = wStart.toLocaleDateString("en-US", { month: "short", day: "numeric" });
              weeklyData.push({ week: label, longRun: parseFloat(longRunMi.toFixed(1)), target: parseFloat(longRunTargetMi.toFixed(1)) });
            }

            // Pace progress: quality sessions in the last 16 weeks.
            // Detection (qualityRunType, defined above) is layered: linked
            // plan day → Strava workout_type tag → classifyRunIntent → a
            // peak-HR fallback for sessions whose whole-run average got
            // dragged easy by warmups and recovery jogs.
            // For a quality session the displayed pace is the avg of the
            // WORK reps, not the whole-run avg — otherwise a 6×800 at 5:40
            // shows up as 7:10 once warmup + recovery jogs are mixed in.
            // Work is isolated from the per-second stream (preferred, finer)
            // or the laps; each analyzer also reports the typical rep length
            // so the chart can size / colour each session.
            const obsMaxHR = Math.max(150, ...runs.map(r => r.max_heartrate || 0));
            // HR cut for "work": above the grey-zone ceiling (~80% maxHR).
            // Tempo, threshold and VO2 all sit above this — easy/warmup below.
            const HR_WORK_FRAC = 0.80;
            // Anything slower than 7:30/mi isn't an "effort" — it's aerobic
            // running with a brief surge. Lives up here (above the analyzers)
            // so the stream cut can cap at it, ensuring every rep that ends
            // up on the chart was also classified as work by the analyzer
            // and vice-versa.
            const EFFORT_PACE_MAX = 450;
            // Each analyzer now exposes the individual reps it found, not a
            // single aggregate pace — the chart plots one dot per rep so a
            // 6×stride session reads as 6 small dots, not one mid-sized dot.
            const analyzeWorkFromLaps = (laps) => {
              if (!Array.isArray(laps) || laps.length < 2) return null;
              const valid = laps.filter(l => l.distance > 50 && l.moving_time > 0);
              if (!valid.length) return null;
              const paceOf = l => l.moving_time / (l.distance / 1609.34);
              const toRep = l => ({ lenM: l.distance, durS: l.moving_time, pace: paceOf(l) });
              // Garmin lap names first. A structured workout pushed from
              // Garmin Connect tags each lap "Active"/"Rest"/"Warm Up"/etc.,
              // and Strava preserves the string verbatim. When we have them,
              // they're more reliable than any HR or pace heuristic.
              const labelled = valid.map(l => {
                const n = String(l.name || "").toLowerCase();
                const isWork = /active|interval|work|rep|tempo|threshold|race/.test(n);
                const isRest = /rest|recover|jog|warm.?up|cool.?down/.test(n);
                return { lap: l, isWork: isWork && !isRest };
              });
              const namedWork = labelled.filter(x => x.isWork).map(x => x.lap);
              if (namedWork.length >= 2) {
                const totD = namedWork.reduce((s, l) => s + l.distance, 0);
                // Same stride-aware floor as the stream analyzer: a structured
                // stride set (named "Active" laps, <250m each) can total under
                // 400m yet still be real work.
                const allStrides = namedWork.every(l => l.distance < 250);
                if (totD >= (allStrides ? 150 : 400)) return { reps: namedWork.map(toRep) };
              }
              // HR-first: if laps carry HR, pick the ones in tempo+ zones.
              const hrCut = obsMaxHR * HR_WORK_FRAC;
              const hrLaps = valid.filter(l => l.average_heartrate > 0);
              if (hrLaps.length >= 2) {
                const work = hrLaps.filter(l => l.average_heartrate >= hrCut);
                const workDist = work.reduce((s, l) => s + l.distance, 0);
                if (work.length && workDist >= 400) return { reps: work.map(toRep) };
              }
              // Pace-spread fallback for legacy/no-HR laps.
              if (valid.length < 3) return null;
              const paces = valid.map(paceOf);
              const lo = Math.min(...paces), hi = Math.max(...paces);
              if (hi - lo < 60) return null;
              const cut = lo + (hi - lo) * 0.4;
              const fast = valid.filter(l => paceOf(l) < cut);
              if (fast.length < 2) return null;
              const totD = fast.reduce((s, l) => s + l.distance, 0);
              if (!(totD > 0)) return null;
              return { reps: fast.map(toRep) };
            };
            const analyzeWorkFromStreamHR = (streams) => {
              if (!Array.isArray(streams) || streams.length < 12) return null;
              const pts = streams
                .filter(s => s && s.d != null && s.t != null && s.v > 0.5 && s.hr > 0)
                .map(s => ({ d: s.d, t: s.t, hr: s.hr }));
              if (pts.length < 12) return null;
              const cut = obsMaxHR * HR_WORK_FRAC;
              const reps = [];
              let repD = 0, repT = 0;
              const flush = () => {
                if (repD > 150) reps.push({ lenM: repD, durS: repT, pace: repT / (repD / 1609.34) });
                repD = 0; repT = 0;
              };
              for (let i = 1; i < pts.length; i++) {
                const dd = pts[i].d - pts[i - 1].d, dt = pts[i].t - pts[i - 1].t;
                if (dd <= 0 || dt <= 0 || dt > 30) { flush(); continue; }
                if (pts[i].hr >= cut) { repD += dd; repT += dt; }
                else flush();
              }
              flush();
              const totD = reps.reduce((s, r) => s + r.lenM, 0);
              if (totD < 400 || !reps.length) return null;
              return { reps };
            };
            const analyzeWorkFromStream = (streams) => {
              if (!Array.isArray(streams) || streams.length < 12) return null;
              const pts = streams
                .filter(s => s && s.d != null && s.t != null && s.v > 0.5)
                .map(s => ({ d: s.d, t: s.t, pace: 1609.34 / s.v }));
              if (pts.length < 12) return null;
              const sorted = pts.map(p => p.pace).sort((a, b) => a - b);
              const pctl = q => sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))];
              const lo = pctl(0.1), hi = pctl(0.9);
              if (hi - lo < 45) return null;
              // 0.5 cut (not 0.4) so a pyramid's slowest rep — 1000m at 6:30
              // alongside 200m at 4:30 — still falls below the cut. Capped at
              // EFFORT_PACE_MAX so the analyzer's "work" boundary matches the
              // chart's 7:30 effort filter — anything the analyzer counts as
              // work also makes it onto the chart, and vice-versa.
              const cut = Math.min(EFFORT_PACE_MAX, lo + (hi - lo) * 0.5);
              const reps = [];
              let repD = 0, repT = 0;
              // Min rep length is 60m (down from 150m) so 80-100m strides
              // get classified as reps rather than vanishing as noise.
              const flush = () => {
                if (repD > 60) reps.push({ lenM: repD, durS: repT, pace: repT / (repD / 1609.34) });
                repD = 0; repT = 0;
              };
              for (let i = 1; i < pts.length; i++) {
                const dd = pts[i].d - pts[i - 1].d, dt = pts[i].t - pts[i - 1].t;
                if (dd <= 0 || dt <= 0 || dt > 30) { flush(); continue; }
                if (pts[i].pace < cut) { repD += dd; repT += dt; }
                else flush();
              }
              flush();
              const totD = reps.reduce((s, r) => s + r.lenM, 0);
              // 400m total-work floor screens out incidental surges — but a
              // short stride set (4×80m = 320m) is real work that the floor
              // was silently dropping. Lower the bar to 150m when EVERY rep is
              // stride-length (<250m); the strict <6:30 stride pace cap
              // downstream still rejects easy-run pace blips, so this only
              // admits genuine fast strides.
              if (!reps.length) return null;
              const allStrides = reps.every(r => r.lenM < 250);
              if (totD < (allStrides ? 150 : 400)) return null;
              return { reps };
            };
            // Order: pace-spread first (cleanest for intervals with clear
            // work/rest gap), HR for uniform-pace tempos, then HR-aware laps.
            const analyzeQualitySession = (detail) =>
              detail ? (analyzeWorkFromStream(detail.streams) || analyzeWorkFromStreamHR(detail.streams) || analyzeWorkFromLaps(detail.laps)) : null;
            // Each rep becomes its own dot — 6 strides = 6 small dots,
            // 4×1km = 4 medium dots, 4×10min = 4 large dots. Replaces the old
            // one-dot-per-session model that hid rep count and dragged stride
            // bursts into the same visual mass as sustained tempos.
            const allReps = [];
            const cutoff = new Date(now.getTime() - weeks * 7 * 86400000);
            runs.forEach(r => {
              const rd = r.start_date?.toDate ? r.start_date.toDate() : new Date(r.start_date);
              if (rd < cutoff || !r.distance || r.distance < 1000 || !r.moving_time) return;
              const q = qualityRunType(r, obsMaxHR);
              if (!q) return;
              const distMi = r.distance / 1609.34;
              const wholeRunPace = r.moving_time / distMi;
              const work = analyzeQualitySession(qualityDetail[String(r.id)]);
              if (q.loose && (!work || !work.reps?.length)) return;
              const dateMs = rd.getTime();
              const meta = {
                sessionName: r.name || "Workout",
                sessionDist: parseFloat(distMi.toFixed(1)),
                sessionType: q.label,
                dateLabel: rd.toLocaleDateString("en-US", { month: "short", day: "numeric" }),
              };
              if (work && work.reps?.length) {
                // Spread reps within a session along ±4h jitter so identical-
                // pace reps don't collapse onto the same pixel.
                const fast = work.reps.filter(rep => rep.pace <= EFFORT_PACE_MAX);
                const n = fast.length;
                const span = Math.min(n, 8) * 3600000;
                // Strides (<250m) have GPS-noisy per-rep pace — a 100m rep's
                // distance error (and where the stream cut clips its accel/
                // decel) swings the computed pace ±20-30s/mi even at identical
                // effort, smearing a set of same-pace strides into a vertical
                // line. Anchor every stride in a session at the session's
                // MEDIAN stride pace so they cluster at one height (dot count
                // still shows). Intervals/tempo reps (≥250m) keep their own
                // pace — those are long enough for a reliable per-rep read.
                const stridePaces = fast.filter(rep => rep.lenM < 250).map(rep => rep.pace).sort((a, b) => a - b);
                const strideMedian = stridePaces.length ? stridePaces[Math.floor((stridePaces.length - 1) / 2)] : null;
                fast.forEach((rep, i) => {
                  const jitter = n > 1 ? (i / (n - 1) - 0.5) * span : 0;
                  const repPace = (rep.lenM < 250 && strideMedian != null) ? strideMedian : rep.pace;
                  allReps.push({
                    ...meta,
                    dateMs: dateMs + jitter,
                    pace: Math.round(repPace),
                    lenM: Math.round(rep.lenM),
                    durS: Math.round(rep.durS),
                    isWorkPace: true,
                  });
                });
              } else if (wholeRunPace <= EFFORT_PACE_MAX) {
                allReps.push({
                  ...meta,
                  dateMs,
                  pace: Math.round(wholeRunPace),
                  lenM: null,
                  durS: r.moving_time,
                  isWorkPace: false,
                });
              }
            });
            allReps.sort((a, b) => a.dateMs - b.dateMs);

            // Three categories the user actually trains: short bursts,
            // VO2/anaerobic intervals, sustained threshold/tempo blocks.
            //
            // Per-category pace caps drop reps that are the right LENGTH
            // for a category but the wrong INTENSITY — a 200m rep at
            // 6:54/mi isn't a stride (it's a surge inside an easy run),
            // and a 500m rep at 7:22/mi isn't VO2max work. Without these
            // caps the chart paints easy-run pace bursts as quality
            // sessions and the category trend lines flatten out.
            const CATEGORY_PACE_CAP = {
              stride: 6 * 60 + 30,    // <6:30/mi — real strides only
              interval: 7 * 60,       // <7:00/mi — real VO2max-ish reps only
              threshold: 7 * 60 + 30, // <7:30/mi — threshold/tempo (matches EFFORT_PACE_MAX)
            };
            const categoryOf = (lenM, type, pace) => {
              let cat;
              if (lenM != null) {
                if (lenM < 250) cat = "stride";
                else if (lenM < 2000) cat = "interval";
                else cat = "threshold";
              } else {
                cat = type === "Intervals" ? "interval" : "threshold";
              }
              if (pace > CATEGORY_PACE_CAP[cat]) return null;
              return cat;
            };
            allReps.forEach(r => { r.category = categoryOf(r.lenM, r.sessionType, r.pace); });
            const validReps = allReps.filter(r => r.category != null);
            // Recency → dot opacity: recent reps render solid, older ones
            // fade (floored at 0.3), so the field reads as progress over
            // time leading into the per-category trend line.
            const _ppDms = validReps.map(r => r.dateMs).filter(Number.isFinite);
            const _ppMin = _ppDms.length ? Math.min(..._ppDms) : 0;
            const _ppMax = _ppDms.length ? Math.max(..._ppDms) : 1;
            const recencyOpacity = (dms) => {
              if (!Number.isFinite(dms) || _ppMax <= _ppMin) return 1;
              return Math.round((0.3 + 0.7 * (dms - _ppMin) / (_ppMax - _ppMin)) * 1000) / 1000;
            };

            const CATEGORY = {
              stride:    { color: C.amber, label: "Strides <250m @<6:30" },
              interval:  { color: C.red,   label: "Intervals 250m–2k @<7:00" },
              threshold: { color: C.cyan,  label: "Threshold ≥2k / tempo @<7:30" },
            };

            // Per-category trend: linear regression through every kept rep
            // in the category, rendered as a single straight line from the
            // first rep's date to the last. The previous rolling-3 with
            // monotone interpolation produced swoopy curves that read as
            // noise rather than direction. Two endpoints + linear is the
            // clearest "is pace improving?" signal.
            const linReg = (pts) => {
              const n = pts.length;
              if (n < 3) return null;
              let sumX = 0, sumY = 0, sumXY = 0, sumXX = 0;
              for (const p of pts) {
                sumX += p.dateMs; sumY += p.pace;
                sumXY += p.dateMs * p.pace;
                sumXX += p.dateMs * p.dateMs;
              }
              const denom = n * sumXX - sumX * sumX;
              if (denom === 0) return null;
              const m = (n * sumXY - sumX * sumY) / denom;
              const b = (sumY - m * sumX) / n;
              return { m, b };
            };
            const trendByCategory = {};
            ["stride","interval","threshold"].forEach(cat => {
              const catReps = validReps.filter(r => r.category === cat);
              if (catReps.length < 3) { trendByCategory[cat] = []; return; }
              const reg = linReg(catReps);
              if (!reg) { trendByCategory[cat] = []; return; }
              const x0 = catReps[0].dateMs;
              const x1 = catReps[catReps.length - 1].dateMs;
              trendByCategory[cat] = [
                { dateMs: x0, pace: Math.round(reg.m * x0 + reg.b) },
                { dateMs: x1, pace: Math.round(reg.m * x1 + reg.b) },
              ];
            });
            const sessionCount = new Set(validReps.map(r => r.dateLabel)).size;
            return (
              <div style={{display:"grid",gridTemplateColumns:"1fr",gap:16}}>
                {/* Long Run Progress */}
                <Card>
                  <div style={{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:4}}>
                    <div style={{fontSize:13,fontWeight:600,color:C.textDim}}>Long Run Progress</div>
                    <div style={{fontSize:11,color:C.textMuted}}>Target: {longRunTargetMi.toFixed(0)} mi</div>
                  </div>
                  <div style={{fontSize:10,color:C.textMuted,marginBottom:12}}>
                    Weekly longest run vs fully trained target for {raceName}
                  </div>
                  <ResponsiveContainer width="100%" height={200}>
                    <ComposedChart data={weeklyData} margin={{top:5,right:10,left:0,bottom:5}}>
                      <defs>
                        <linearGradient id="longRunGrad" x1="0" y1="0" x2="0" y2="1">
                          <stop offset="5%" stopColor={C.cyan} stopOpacity={0.3}/>
                          <stop offset="95%" stopColor={C.cyan} stopOpacity={0.02}/>
                        </linearGradient>
                      </defs>
                      <CartesianGrid strokeDasharray="3 3" stroke={C.border} vertical={false} />
                      <XAxis dataKey="week" tick={{fill:C.textMuted,fontSize:9}} axisLine={false} tickLine={false} interval="preserveStartEnd" />
                      <YAxis tick={{fill:C.textMuted,fontSize:10}} axisLine={false} tickLine={false} width={32}
                        tickFormatter={v => v + "mi"} domain={[0, Math.ceil(longRunTargetMi * 1.15)]} />
                      <Tooltip content={({active,payload,label}) => {
                        if (!active || !payload?.length) return null;
                        return (
                          <div style={{background:C.bg,border:`1px solid ${C.border}`,borderRadius:8,padding:"8px 12px",fontSize:11}}>
                            <div style={{fontWeight:600,color:C.text,marginBottom:4}}>{label}</div>
                            {payload.map((p,i) => p.value != null && (
                              <div key={i} style={{color:p.color,display:"flex",gap:8,justifyContent:"space-between"}}>
                                <span>{p.name}:</span>
                                <span style={{fontWeight:700,fontFamily:"'IBM Plex Mono',monospace"}}>{p.value} mi</span>
                              </div>
                            ))}
                          </div>
                        );
                      }} />
                      <ReferenceLine y={longRunTargetMi} stroke={C.green+"60"} strokeDasharray="4 4"
                        label={{value:"Fully Trained",fill:C.green,fontSize:9,position:"right"}} />
                      <Area type="monotone" dataKey="longRun" stroke={C.cyan} fill="url(#longRunGrad)" strokeWidth={2.5}
                        dot={{r:3,fill:C.cyan}} name="Long Run" />
                    </ComposedChart>
                  </ResponsiveContainer>
                  {weeklyData.length > 0 && (() => {
                    const peak = Math.max(...weeklyData.map(w => w.longRun));
                    const pct = Math.min(100, Math.round((peak / longRunTargetMi) * 100));
                    return (
                      <div style={{marginTop:10,display:"flex",alignItems:"center",gap:10}}>
                        <div style={{flex:1,height:6,background:C.bg3,borderRadius:3,overflow:"hidden"}}>
                          <div style={{width:pct+"%",height:"100%",background:pct>=90?C.green:pct>=60?C.cyan:C.amber,borderRadius:3,transition:"width 0.5s"}}></div>
                        </div>
                        <span style={{fontSize:11,fontWeight:700,color:pct>=90?C.green:pct>=60?C.cyan:C.amber,fontFamily:"'IBM Plex Mono',monospace"}}>{pct}%</span>
                      </div>
                    );
                  })()}
                </Card>

                {/* Pace Progress */}
                <Card>
                  <div style={{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:4}}>
                    <div style={{fontSize:13,fontWeight:600,color:C.textDim}}>Pace Progress</div>
                    <div style={{fontSize:11,color:C.textMuted}}>{validReps.length} reps · {sessionCount} sessions</div>
                  </div>
                  <div style={{fontSize:10,color:C.textMuted,marginBottom:12}}>
                    One dot per rep — dot size = rep length, colour by category. Pace caps: strides &lt;6:30, intervals &lt;7:00, tempo &lt;7:30 (last {weeks} weeks)
                  </div>
                  {validReps.length >= 2 ? (
                    <React.Fragment>
                      <ResponsiveContainer width="100%" height={320}>
                        <ComposedChart margin={{top:12,right:18,left:0,bottom:5}}>
                          <defs>
                            <filter id="paceDotGlow" x="-50%" y="-50%" width="200%" height="200%">
                              <feGaussianBlur stdDeviation="3" result="blur" />
                              <feMerge>
                                <feMergeNode in="blur" />
                                <feMergeNode in="SourceGraphic" />
                              </feMerge>
                            </filter>
                          </defs>
                          <CartesianGrid strokeDasharray="3 3" stroke={C.border} vertical={false} />
                          <XAxis dataKey="dateMs" type="number"
                            domain={['dataMin', 'dataMax']}
                            tick={{fill:C.textMuted,fontSize:10}}
                            axisLine={false} tickLine={false}
                            tickFormatter={ms => new Date(ms).toLocaleDateString("en-US",{month:"short",day:"numeric"})} />
                          <YAxis reversed dataKey="pace" type="number"
                            tick={{fill:C.textMuted,fontSize:10}}
                            axisLine={false} tickLine={false} width={42}
                            tickFormatter={v => { const m=Math.floor(v/60), s=Math.round(v%60); return m+":"+String(s).padStart(2,"0"); }}
                            domain={['dataMin - 15', 'dataMax + 15']} />
                          <Tooltip cursor={{strokeDasharray:'3 3'}} content={({active,payload}) => {
                            if (!active || !payload?.length) return null;
                            const d = payload[0]?.payload;
                            if (!d || !d.sessionName) return null;
                            const cat = CATEGORY[d.category];
                            return (
                              <div style={{background:C.bg,border:`1px solid ${C.border}`,borderRadius:8,padding:"8px 12px",fontSize:11}}>
                                <div style={{fontWeight:600,color:C.text,marginBottom:4}}>{d.sessionName}</div>
                                <div style={{color:C.textMuted,marginBottom:2}}>{d.dateLabel} · {d.sessionDist} mi · {d.sessionType}</div>
                                <div style={{color:cat.color,fontWeight:700,fontFamily:"'IBM Plex Mono',monospace"}}>{fmt.pace(d.pace)}</div>
                                {d.lenM != null
                                  ? <div style={{color:C.textMuted,fontSize:10,marginTop:2}}>{d.category} · {d.lenM}m · {d.durS}s</div>
                                  : <div style={{color:C.textMuted,fontSize:10,marginTop:2}}>whole-run average</div>}
                              </div>
                            );
                          }} />
                          {["stride","interval","threshold"].map(cat => {
                            const data = validReps.filter(r => r.category === cat);
                            if (!data.length) return null;
                            const col = CATEGORY[cat].color;
                            return (
                              <Scatter key={cat} name={CATEGORY[cat].label} data={data} dataKey="pace"
                                isAnimationActive={false} shape={(props) => {
                                  const { cx, cy, payload } = props;
                                  if (cx == null || cy == null || !payload) return null;
                                  const lenM = payload.lenM != null ? payload.lenM : 800;
                                  // Area ∝ rep distance: r = a + sqrt(lenM)/k.
                                  // Old formula capped at lenM≈6200m (r=23), so
                                  // anything beyond ~3.85mi flat-lined — a 4.5mi
                                  // threshold rep read identically to a 2mi one.
                                  // New cap (r=34) doesn't kick in until ~8.8mi,
                                  // so 2mi (r≈19) → 4.5mi (r≈27) is visibly +42%.
                                  const r = 3 + Math.min(34, Math.sqrt(lenM) / 3.5);
                                  const alpha = payload.isWorkPace ? "CC" : "55";
                                  return (
                                    <g filter={payload.isWorkPace ? "url(#paceDotGlow)" : undefined} opacity={recencyOpacity(payload.dateMs)}>
                                      <circle cx={cx} cy={cy} r={r} fill={col + alpha} stroke={col} strokeWidth={2} />
                                      <circle cx={cx} cy={cy} r={Math.max(1.5, r * 0.18)} fill={C.bg} opacity={0.85} />
                                    </g>
                                  );
                                }} />
                            );
                          })}
                          {["stride","interval","threshold"].map(cat => {
                            const t = trendByCategory[cat];
                            if (!t || t.length < 2) return null;
                            return (
                              <Line key={`tr-${cat}`} data={t} type="linear" dataKey="pace"
                                stroke={CATEGORY[cat].color} strokeWidth={2}
                                strokeDasharray="4 3" dot={false}
                                name={`${cat} trend`} isAnimationActive={false} />
                            );
                          })}
                        </ComposedChart>
                      </ResponsiveContainer>
                      <div style={{display:"flex",justifyContent:"center",gap:14,marginTop:6,fontSize:9,color:C.textMuted,flexWrap:"wrap"}}>
                        {["stride","interval","threshold"].map(k => (
                          <span key={k} style={{display:"flex",alignItems:"center",gap:4}}>
                            <span style={{width:8,height:8,borderRadius:2,background:CATEGORY[k].color,display:"inline-block"}} />
                            {CATEGORY[k].label}
                          </span>
                        ))}
                      </div>
                      <div style={{marginTop:8,display:"flex",justifyContent:"center",gap:14,fontSize:11,flexWrap:"wrap"}}>
                        {["stride","interval","threshold"].map(cat => {
                          const catReps = validReps.filter(r => r.category === cat);
                          if (catReps.length < 3) return null;
                          const n = Math.max(2, Math.min(3, Math.floor(catReps.length / 2)));
                          const first = catReps.slice(0, n).reduce((s,r) => s + r.pace, 0) / n;
                          const last = catReps.slice(-n).reduce((s,r) => s + r.pace, 0) / n;
                          const delta = first - last;
                          const col = delta > 5 ? C.green : delta > 0 ? CATEGORY[cat].color : C.amber;
                          return (
                            <span key={cat} style={{display:"flex",alignItems:"center",gap:6}}>
                              <span style={{width:8,height:8,borderRadius:4,background:CATEGORY[cat].color}}/>
                              <span style={{color:C.textMuted,fontFamily:"'IBM Plex Mono',monospace"}}>{fmt.pace(Math.round(first)).split(" ")[0]}</span>
                              <span style={{color:col,fontWeight:700,fontFamily:"'IBM Plex Mono',monospace"}}>{delta>0?"↓":"↑"}{Math.abs(Math.round(delta))}</span>
                              <span style={{color:col,fontFamily:"'IBM Plex Mono',monospace"}}>{fmt.pace(Math.round(last)).split(" ")[0]}</span>
                            </span>
                          );
                        })}
                      </div>
                    </React.Fragment>
                  ) : (
                    <div style={{textAlign:"center",padding:"40px 16px",color:C.textMuted,fontSize:12}}>
                      Not enough tempo/interval sessions yet. Link a plan day (Training → tap a session → link activity) or run a hard tempo to seed the chart.
                    </div>
                  )}
                </Card>
              </div>
            );
          })()}

          {/* Race Progress Tracker */}
          {upcomingRaces.length > 0 && upcomingRaces.map(race => {
            const daysAway = Math.ceil((new Date(race.date) - new Date()) / (1000*60*60*24));
            const distMap = { "5K":5000, "10K":10000, "half":21097, "marathon":42195, "ultra":80000 };
            const raceDist = distMap[race.distance] || 42195;
            const raceDistMi = raceDist / 1609.34;

            // Parse goal time (H:MM:SS or M:SS)
            const parseGoal = (s) => {
              if (!s) return null;
              const parts = s.split(":").map(Number);
              if (parts.length === 3) return parts[0]*3600 + parts[1]*60 + parts[2];
              if (parts.length === 2) return parts[0]*60 + parts[1];
              return null;
            };
            const goalSec = parseGoal(race.goalTime);
            const goalPace = goalSec ? goalSec / raceDistMi : null;
            // GPS distance is typically 1-1.5% longer than certified course distance
            const gpsOvershoot = 1.012; // 1.2% — typical GPS wander on marathon courses
            const gpsDistMi = raceDistMi * gpsOvershoot;
            const gpsPace = goalSec ? goalSec / gpsDistMi : null;

            // NY Marathon course profile — used for course-aware insights
            const COURSE_PROFILES = {
              "new york": {
                key: "nyc", city: "New York City",
                elevGainFt: 890, netElevFt: -70,
                bridges: [
                  { name: "Verazano-Narrows Bridge", mile: 1, gainFt: 130, note: "Steep climb at the start — go out conservative, don't chase pace" },
                  { name: "Pulaski Bridge", mile: 13, gainFt: 60, note: "Short but steep into Queens — maintain effort not pace" },
                  { name: "Queensboro Bridge", mile: 15.5, gainFt: 130, note: "Long silent grind, no crowd support — stay mentally strong" },
                  { name: "Willis Ave Bridge", mile: 20, gainFt: 35, note: "Short climb into the Bronx at a critical fatigue point" },
                  { name: "Madison Ave Bridge", mile: 21, gainFt: 25, note: "Back to Manhattan — final bridges done" },
                ],
                tactics: [
                  "Start conservatively on the Verazano — the first mile is all uphill. Bank patience, not time.",
                  "Miles 16-20 through the Bronx are the race. If you're even-splitting here, you're racing well.",
                  "The Queensboro Bridge (mile 15.5) is the longest climb and has no crowd energy. Prepare mentally.",
                  "Central Park South (miles 24-26) has rolling hills — keep turnover high, don't collapse form.",
                  "For sub-3:00, you need ~6:50/mi including the bridges. Train for hills and negative-split the second half.",
                ],
                trainingRecs: [
                  "Include bridge repeats or hilly tempo runs (600-900ft gain) in training",
                  "Practice starting conservative on uphills — first 2mi should be 10-15s/mi slower than goal",
                  "Long runs with late-race hills simulate the Bronx/Central Park finish",
                ],
              },
            };
            const courseKey = Object.keys(COURSE_PROFILES).find(k => race.name?.toLowerCase().includes(k));
            const course = courseKey ? COURSE_PROFILES[courseKey] : null;

            // Training volume analysis — use last 12 weeks of running data
            const now = new Date();
            const planStartDate = activePlan?.createdAt?.seconds
              ? new Date(activePlan.createdAt.seconds * 1000)
              : activePlan?.createdAt ? new Date(activePlan.createdAt) : null;
            const weeksData = [];
            for (let w = 0; w < 12; w++) {
              // w=0 = current week (Monday..today), w=1 = last week, etc.
              const wStart = new Date(now); wStart.setDate(now.getDate() - w*7 - ((now.getDay() + 6) % 7)); wStart.setHours(0,0,0,0);
              const wEnd = new Date(wStart); wEnd.setDate(wStart.getDate() + 7);
              const wRuns = runs.filter(a => {
                const ad = a.start_date?.toDate ? a.start_date.toDate() : new Date(a.start_date);
                return ad >= wStart && ad < wEnd;
              });
              weeksData.push({
                wStart,
                miles: wRuns.reduce((s,a) => s + a.distance/1609.34, 0),
                longRun: wRuns.length > 0 ? Math.max(...wRuns.map(a => a.distance/1609.34)) : 0,
                count: wRuns.length,
                avgPace: wRuns.length > 0 ? wRuns.reduce((s,a) => s + (a.moving_time/(a.distance/1609.34||1)), 0) / wRuns.length : 0,
                // Per-run HR data — used by the Easy HR control milestone,
                // which wants the fraction of easy-intent runs whose avg
                // HR landed in Z1 (below the athlete's configured Z1
                // ceiling). Filter out Strava-tagged races (workout_type
                // 1) and quality workouts (workout_type 3) — those are
                // *supposed* to exceed Z1 and only pollute the signal.
                // Default (0/null) and Long Run (2) are kept; both are
                // easy-intent in polarized training.
                runsWithHR: wRuns
                  .filter(a => a.average_heartrate > 0 && a.workout_type !== 1 && a.workout_type !== 3)
                  .map(a => a.average_heartrate),
                // Quality (threshold/interval/race) sessions logged this
                // week — Strava race (1) / workout (3) flags plus our own
                // race/hard intent classifier. Feeds the "Threshold
                // sessions" build-phase milestone, which previously read a
                // hardcoded 0 and so never moved.
                quality: wRuns.filter(a => a.workout_type === 1 || a.workout_type === 3 ||
                  (window.RaceMath && ["race", "hard"].indexOf(window.RaceMath.classifyRunIntent(a)) >= 0)).length,
              });
            }
            // Filter to weeks that overlap the plan period; fall back to all 12 if no plan start date
            const activeWeeks = planStartDate
              ? weeksData.filter(wd => new Date(wd.wStart.getTime() + 7*86400000) > planStartDate)
              : weeksData;
            const planWeekCount = Math.max(1, activeWeeks.length);
            const weeksWithRuns = activeWeeks.filter(w => w.count > 0);
            // The current week (index 0 = Monday..today) is still in
            // progress, so its mileage is partial. Including it in the
            // rolling weekly-volume average dragged the number well below
            // actual training load (e.g. three 45mi weeks + a 12mi
            // mid-week partial read as ~36 mi/wk). Average over COMPLETED
            // weeks only, falling back to all weeks if none have finished.
            const completedWeeksWithRuns = weeksWithRuns.filter(w =>
              new Date(w.wStart.getTime() + 7 * 86400000) <= now);
            const volWeeks = completedWeeksWithRuns.length > 0 ? completedWeeksWithRuns : weeksWithRuns;
            const last4 = activeWeeks.slice(0, Math.min(4, planWeekCount));
            const prior4 = activeWeeks.slice(4, 8);
            const avgMilesLast4 = volWeeks.length > 0 ? volWeeks.slice(0, 4).reduce((s,w) => s + w.miles, 0) / Math.min(4, volWeeks.length) : 0;
            // Average quality sessions per completed week (last 4) — drives
            // the build-phase "Threshold sessions" milestone.
            const qualityPerWeek = volWeeks.length > 0
              ? volWeeks.slice(0, 4).reduce((s,w) => s + (w.quality || 0), 0) / Math.min(4, volWeeks.length)
              : 0;
            const avgMilesPrior4 = prior4.filter(w=>w.count>0).length > 0 ? prior4.filter(w=>w.count>0).reduce((s,w) => s + w.miles, 0) / prior4.filter(w=>w.count>0).length : 0;
            const longestRun = Math.max(...weeksData.map(w => w.longRun), 0);
            const avgPaceLast4 = weeksWithRuns.filter(w => w.avgPace > 0).reduce((s,w) => s + w.avgPace, 0) / (weeksWithRuns.filter(w => w.avgPace > 0).length || 1);

            // Easy HR control: percentage of runs (last 4 weeks) whose avg
            // HR landed in Z1 per the athlete's configured zones. Prior
            // version was accidentally comparing avgPace against the
            // hard-coded Z1 ceiling (144), which is a bpm value not a
            // pace, so the metric always read 0.0.
            const z1Ceiling = (() => {
              try {
                const zones = getHRZones(hrZoneConfig)?.zones || [];
                const z1 = zones.find(z => z.zone === 1);
                return z1?.maxBPM || 144;
              } catch (e) { return 144; }
            })();
            const last4HRs = last4.flatMap(w => w.runsWithHR || []);
            const easyHrControlPct = last4HRs.length > 0
              ? Math.round(last4HRs.filter(hr => hr < z1Ceiling).length / last4HRs.length * 100)
              : 0;

            // Projected race time. Uses the same predictRaceTimes
            // grounding as the Race Time Projections panel (Daniels VDOT
            // off effectiveVO2, Riegel-blended with the best recent
            // QUALITY effort) so all panels show the same projection.
            //
            // BUT — only show the projection when there's actually a
            // recent quality effort to ground it in. Without one,
            // predictRaceTimes falls back to pure Daniels VDOT off
            // effectiveVO2, which produces an "aspirational" number
            // derived from VO2max alone (e.g. 3:09 marathon from
            // Garmin VO2 48). That's misleading on a race-readiness
            // card — race projections should reflect demonstrated
            // capability, not theoretical ceiling. Hide the number
            // and prompt for a quality session instead.
            const recentQuality = bestRecentQualityEffort(runs, 90);
            const distNameMap = {
              "5K": "5K", "10K": "10K", "half": "Half Marathon", "marathon": "Marathon",
            };
            // `projections` is declared at the top of Races() (around
            // line 7531). Don't refer to Dashboard's `racePredictions`
            // — it isn't in scope here and renders the whole panel as
            // a ReferenceError.
            const projectionMatch = projections && recentQuality
              ? projections.find(rp => rp.name === distNameMap[race.distance])
              : null;
            const projectedSec = projectionMatch ? projectionMatch.seconds : null;
            // Course adjustment: NY Marathon adds ~2-3min from bridges/elevation
            const courseAdjSec = course ? Math.round(course.elevGainFt * 0.2) : 0; // ~0.2s per ft of gain
            const adjustedProjectedSec = projectedSec ? projectedSec + courseAdjSec : null;
            const projectedPace = adjustedProjectedSec ? adjustedProjectedSec / raceDistMi : null;

            // Plan stats for honest assessment (scoped to plan period)
            const planTotalMi = activeWeeks.reduce((s,w) => s + w.miles, 0);
            const planWeeksCount = Math.max(1, weeksWithRuns.length);
            const planAvgMiPerWeek = planTotalMi > 0 ? avgMilesLast4 : 0;  // use the weekly average, not total/weeks

            // Readiness scores (0-100)
            // Use current plan week's targets rather than peak targets — scoring against
            // where you *should* be now, not where you need to be at peak (30 weeks out
            // shouldn't score against a 45 mi/wk peak target).
            const isSub3Goal = goalSec && goalSec <= 10800;
            const peakVolumeTarget = isSub3Goal ? 45 : raceDistMi * 0.6;
            const peakLongRunTarget = isSub3Goal ? 20 : raceDistMi * 0.75;
            // Find the current plan week (0-indexed from plan start date)
            const planWeekIndex = planStartDate
              ? Math.floor((now - planStartDate.getTime()) / (7 * 86400000))
              : 0;
            const currentPlanWeek = activePlan?.weeks?.[planWeekIndex];
            const currentPlanLongDay = currentPlanWeek?.days?.find(d => d.type === "long");
            // Phase-appropriate targets: use this week's plan numbers if available,
            // otherwise scale peak target by how far into the plan we are (min 30%)
            const planLengthWeeks = activePlan?.weeksTotal || 30;
            const phaseScale = Math.max(0.3, Math.min(1, (planLengthWeeks - (planWeekIndex || 0)) < 16
              ? 1                                                            // final 16 weeks → full peak target
              : 0.3 + 0.7 * (planWeekIndex || 0) / Math.max(1, planLengthWeeks - 16)));
            const volumeTarget = currentPlanWeek?.targetMiles || Math.round(peakVolumeTarget * phaseScale);
            const longRunTarget = currentPlanLongDay?.distanceMi || Math.round(peakLongRunTarget * phaseScale);
            const volumeScore = Math.min(100, (avgMilesLast4 / volumeTarget) * 100);
            const longRunScore = Math.min(100, (longestRun / longRunTarget) * 100);
            const fitnessScore = adjustedProjectedSec && goalSec ? Math.min(100, (goalSec / adjustedProjectedSec) * 100) : 50;
            const consistencyScore = Math.min(100, (last4.filter(w => w.count >= 3).length / 4) * 100);

            // Sub-3:00 probability model — factors: projected time gap, volume, long run, consistency, days out
            const calcSub3Probability = () => {
              if (!goalSec || goalSec > 10800 || !adjustedProjectedSec) return null;
              const gapMin = (adjustedProjectedSec - 10800) / 60; // minutes above 3:00:00
              const gpsRaceMi = raceDistMi * 1.012; // GPS race distance: actual GPS will read ~26.5mi
              const weeksLeft = daysAway / 7;

              // Internal scoring (drives the % at the top — bucketed for stability)
              const gapFactor = gapMin <= 0 ? 35 : gapMin < 5 ? 30 : gapMin < 15 ? 20 : gapMin < 30 ? 10 : gapMin < 45 ? 5 : 2;
              const volFactor = avgMilesLast4 >= 50 ? 25 : avgMilesLast4 >= 40 ? 20 : avgMilesLast4 >= 30 ? 12 : avgMilesLast4 >= 20 ? 6 : 2;
              const lrFactor = longestRun >= 22 ? 20 : longestRun >= 20 ? 17 : longestRun >= 18 ? 12 : longestRun >= 15 ? 7 : 3;
              const conFactor = consistencyScore >= 75 ? 10 : consistencyScore >= 50 ? 6 : 3;
              const timeFactor = weeksLeft >= 20 ? 10 : weeksLeft >= 14 ? 8 : weeksLeft >= 8 ? 5 : 2;
              const prob = Math.min(95, Math.max(1, gapFactor + volFactor + lrFactor + conFactor + timeFactor));

              // Concrete progress signals — what each bar actually represents.
              // The bar fills toward a coach-style target so "Long Runs 3 / 8"
              // means something the athlete can act on, instead of "7/20 pts".
              const LR_THRESHOLD_MI = 18, LR_TARGET = 8;
              const longRunCount = (runs || []).filter(r => {
                if (!r.distance) return false;
                if (planStartDate) {
                  const d = r.start_date?.toDate ? r.start_date.toDate() : new Date(r.start_date);
                  if (d < planStartDate) return false;
                }
                return r.distance / 1609.34 >= LR_THRESHOLD_MI;
              }).length;
              const consistentWeeks = last4.filter(w => w.count >= 3).length;
              const wks = Math.max(0, Math.round(weeksLeft));

              const colorFor = (pct) => pct >= 90 ? C.green : pct >= 60 ? C.cyan : pct >= 30 ? C.amber : C.red;
              const progress = [
                { label:"Fitness Gap",
                  value: gapMin <= 0 ? "On goal" : `+${Math.round(gapMin)} min`,
                  target:"vs 3:00:00",
                  pct: gapMin <= 0 ? 100 : Math.max(0, 100 - (gapMin / 30) * 100),
                  color: gapMin <= 0 ? C.green : gapMin < 5 ? C.cyan : gapMin < 15 ? C.amber : C.red,
                  desc:"Projected finish vs 3:00 goal" },
                { label:"Volume",
                  value: `${Math.round(avgMilesLast4)} mi/wk`,
                  target:"50 mi/wk",
                  pct: Math.min(100, (avgMilesLast4 / 50) * 100),
                  color: colorFor(Math.min(100, (avgMilesLast4 / 50) * 100)),
                  desc:"4-week rolling avg. Sub-3 baseline: 50+" },
                { label:"Long Runs",
                  value: `${longRunCount}`,
                  target:`${LR_TARGET} × ≥${LR_THRESHOLD_MI}mi`,
                  pct: Math.min(100, (longRunCount / LR_TARGET) * 100),
                  color: colorFor(Math.min(100, (longRunCount / LR_TARGET) * 100)),
                  desc:`Runs ≥${LR_THRESHOLD_MI}mi logged during the plan` },
                { label:"Consistency",
                  value: `${consistentWeeks}/4`,
                  target:"weeks ≥3 runs",
                  pct: (consistentWeeks / 4) * 100,
                  color: colorFor((consistentWeeks / 4) * 100),
                  desc:"Last 4 weeks with at least 3 runs" },
                { label:"Time Left",
                  value: `${wks} wk${wks === 1 ? "" : "s"}`,
                  target:"≥16 wks ideal",
                  pct: Math.min(100, (wks / 16) * 100),
                  color: wks >= 16 ? C.green : wks >= 10 ? C.cyan : wks >= 4 ? C.amber : C.red,
                  desc:"Weeks until race day" },
              ];

              return { prob, gapMin: Math.round(gapMin), gpsRaceMi: gpsRaceMi.toFixed(1), progress };
            };
            const sub3Prob = calcSub3Probability();
            const overallReadiness = Math.round((volumeScore * 0.25 + longRunScore * 0.25 + fitnessScore * 0.3 + consistencyScore * 0.2));

            const readinessColor = overallReadiness >= 75 ? C.green : overallReadiness >= 50 ? C.amber : C.red;
            const readinessLabel = overallReadiness >= 80 ? "Race Ready" : overallReadiness >= 60 ? "On Track" : overallReadiness >= 40 ? "Building" : "Early Phase";

            // Phase-specific milestone targets for sub-3:00 marathon.
            //
            // The phase segmentation (names + week ranges + which is
            // current) comes from the SAME source as the Plan tab —
            // PlanMath.planStructure(activePlan) — so the two tabs can no
            // longer disagree (Plan said 31wk·7/8/16 while this view used
            // a hardcoded 1-10/11-20/21-29 split off race-date math).
            // Only the milestone *content* below is sub-3 coaching keyed
            // to phase kind. The legacy race-date split is kept solely as
            // the fallback for athletes with a sub-3 goal but no generated
            // plan yet.
            const weeksToRace = Math.ceil(daysAway / 7);
            const sub3Content = {
              base: {
                focus: "Build aerobic engine — volume, consistency, easy pace efficiency. No speed work.",
                milestones: [
                  { label: "Weekly volume", current: avgMilesLast4, target: 30, unit: "mi/wk", desc: "Build to 30 mi/wk consistently" },
                  { label: "Long run", current: longestRun, target: 15, unit: "mi", desc: "Reach 15mi long run" },
                  { label: "Runs per week", current: last4.length > 0 ? last4.reduce((s,w)=>s+w.count,0)/last4.length : 0, target: 4, unit: "/wk", desc: "Average 4 runs per week" },
                  { label: "Easy HR control", current: easyHrControlPct, target: 80, unit: "% Z1", desc: `80%+ of easy/long runs with avg HR under Z1 ceiling (${z1Ceiling} bpm) — races and quality workouts excluded` },
                ],
              },
              build: {
                focus: "Introduce threshold work. Tempo runs and marathon-pace long run segments.",
                milestones: [
                  { label: "Weekly volume", current: avgMilesLast4, target: 45, unit: "mi/wk", desc: "Peak at 40-50 mi/wk" },
                  { label: "Long run", current: longestRun, target: 20, unit: "mi", desc: "Reach 20mi with MP finish" },
                  { label: "Projected time", current: adjustedProjectedSec ? Math.round(adjustedProjectedSec/60) : 999, target: 195, unit: "min", desc: "Projection under 3:15", invert: true },
                  { label: "Threshold sessions", current: qualityPerWeek, target: 2, unit: "/wk", desc: "2 quality sessions per week" },
                ],
              },
              peak: {
                focus: "Race-specific sharpening then volume reduction. Trust the training.",
                milestones: [
                  { label: "Peak volume", current: Math.max(...weeksData.map(w=>w.miles)), target: 50, unit: "mi", desc: "Hit 50mi peak week" },
                  { label: "Long run", current: longestRun, target: 22, unit: "mi", desc: "22mi peak long run" },
                  { label: "Projected time", current: adjustedProjectedSec ? Math.round(adjustedProjectedSec/60) : 999, target: 180, unit: "min", desc: "Projection at or near 3:00", invert: true },
                  { label: "Race pace confidence", current: 0, target: 6, unit: "mi@MP", desc: "6+ miles at marathon pace in single session" },
                ],
              },
            };
            const phaseKind = (name) => /peak/i.test(name) ? "peak" : /build/i.test(name) ? "build" : "base";
            const planStruct = activePlan
              ? window.PlanMath.planStructure(activePlan, window.PlanMath.currentPlanWeek(activePlan))
              : null;
            let phases = null;
            if (isSub3Goal) {
              if (planStruct && planStruct.phases.length) {
                phases = planStruct.phases.map(p => {
                  const c = sub3Content[phaseKind(p.name)];
                  return {
                    name: p.label, tag: p.name, weeks: p.weekRange,
                    active: planStruct.currentPhase ? p.index === planStruct.currentPhase.index : p.index === 0,
                    focus: c.focus, milestones: c.milestones,
                  };
                });
              } else {
                // No generated plan — fall back to the race-date heuristic
                // so the readiness view still renders before plan creation.
                phases = [
                  { name: "Base Building", tag: "Conditioning", weeks: "1–10", active: weeksToRace > 19, ...sub3Content.base },
                  { name: "Build Phase", tag: "Build", weeks: "11–20", active: weeksToRace <= 19 && weeksToRace > 9, ...sub3Content.build },
                  { name: "Peak & Taper", tag: "Peak", weeks: "21–29", active: weeksToRace <= 9, ...sub3Content.peak },
                ];
              }
            }
            const activePhase = phases?.find(p => p.active) || phases?.[0];

            // Derive training plan phase context for gating insights
            const planCreated = activePlan?.createdAt?.seconds
              ? new Date(activePlan.createdAt.seconds * 1000)
              : activePlan?.createdAt ? new Date(activePlan.createdAt) : null;
            const planWeekNum = planCreated
              ? Math.min(activePlan.weeksTotal || 99, Math.max(1, Math.ceil((new Date() - planCreated) / (7*24*60*60*1000))))
              : null;
            const planPhase = (planWeekNum != null && activePlan?.weeks?.[planWeekNum - 1]?.phase) || null;
            // Phase 1 = weeks 1–8 (aerobic base / Conditioning) — suppress performance-intensity cues
            const isPhase1 = planWeekNum != null && planWeekNum <= 8;
            const phaseTag = planPhase ? ` — ${planPhase} phase, week ${planWeekNum}` : planWeekNum ? ` — week ${planWeekNum} of plan` : "";

            // Insights — honest, course-aware, phase-aware
            const insights = [];

            // Honest sub-3hr assessment
            if (goalSec && goalSec <= 10800 && race.distance === "marathon") {
              const sub3Pace = 10800 / raceDistMi; // ~6:52/mi
              if (planAvgMiPerWeek < 30) {
                insights.push({ text: `Sub-3:00 requires 40-55 mi/wk at peak. Current: ${planAvgMiPerWeek.toFixed(1)} mi/wk · this week's target: ${volumeTarget} mi — build steadily${phaseTag}`, color: C.amber });
              }
              if (longestRun < 16) {
                insights.push({ text: `Long run needs to reach 20-22mi for sub-3:00. Current longest: ${longestRun.toFixed(1)}mi — ${daysAway > 90 ? "on track if you build steadily" : "prioritize long runs now"}`, color: longestRun < 12 ? C.red : C.amber });
              }
            }

            // Volume trends
            if (avgMilesLast4 > avgMilesPrior4 * 1.15) insights.push({ text: `Volume up ${((avgMilesLast4/avgMilesPrior4-1)*100).toFixed(0)}%${phaseTag} — watch for overtraining`, color: C.amber });
            else if (avgMilesLast4 > avgMilesPrior4) insights.push({ text: `Volume trending up (+${(avgMilesLast4-avgMilesPrior4).toFixed(1)} mi/wk avg)${phaseTag}`, color: C.green });
            else if (avgMilesPrior4 > 0) insights.push({ text: `Volume declining — ${(avgMilesPrior4-avgMilesLast4).toFixed(1)} mi/wk less than prior block${phaseTag}`, color: C.amber });

            if (longestRun < raceDistMi * 0.5 && daysAway < 60) {
              insights.push({ text: `Long run (${longestRun.toFixed(1)}mi) below ${Math.round(raceDistMi*0.5)}mi target — prioritize long runs${phaseTag}`, color: C.red });
            } else if (longestRun >= raceDistMi * 0.75) {
              insights.push({ text: `Long run ${longestRun.toFixed(1)}mi covers ${Math.round(longestRun/raceDistMi*100)}% of race distance${phaseTag}`, color: C.green });
            }

            if (goalPace && projectedPace) {
              const comparePace = gpsPace || goalPace;
              const gapSec = projectedPace - comparePace;
              if (gapSec > 15) {
                if (isPhase1) {
                  insights.push({ text: `${Math.round(gapSec)}s/mi from goal pace${phaseTag} — aerobic base phase, pace fitness will develop in Phase 2`, color: C.cyan });
                } else {
                  insights.push({ text: `${Math.round(gapSec)}s/mi from GPS-adjusted goal pace (${fmt.pace(comparePace).split(" ")[0]}/mi)${phaseTag} — tempo/threshold work needed`, color: C.amber });
                }
              } else if (gapSec > 0) {
                insights.push({ text: `Within ${Math.round(gapSec)}s/mi of GPS goal pace${phaseTag} — sharpen with race-pace intervals`, color: C.cyan });
              } else {
                insights.push({ text: `Fitness exceeds goal pace by ${Math.abs(Math.round(gapSec))}s/mi${phaseTag} — consider updating goal`, color: C.green });
              }
            }

            // Course-specific insights
            if (course) {
              insights.push({ text: `${race.name} has ${course.elevGainFt}ft of climbing across ${course.bridges.length} bridges — include weekly hill/bridge work in training`, color: C.cyan });
              // Check if training has enough elevation
              const recentElevGain = runs.slice(0, 10).reduce((s, a) => s + (a.total_elevation_gain || 0), 0) / Math.max(1, runs.slice(0, 10).length);
              if (recentElevGain < 30) { // < 100ft avg per run = flat training
                insights.push({ text: `Recent runs are flat (avg ${Math.round(recentElevGain * 3.28)}ft gain). The ${course.bridges[0].name} climbs ${course.bridges[0].gainFt}ft in mile 1 — add hilly routes`, color: C.amber });
              }
              if (courseAdjSec > 0 && adjustedProjectedSec) {
                insights.push({ text: `Course-adjusted projection adds ~${Math.floor(courseAdjSec/60)}:${String(courseAdjSec%60).padStart(2,"0")} for bridge climbs (${course.elevGainFt}ft total)`, color: C.textMuted });
              }
            }

            if (daysAway <= 14) insights.push({ text: "Taper phase — reduce volume 40-60%, keep intensity", color: C.cyan });
            else if (daysAway <= 21) insights.push({ text: "Pre-taper — begin reducing volume 20%, last hard session this week", color: C.cyan });

            const ScoreBar = ({ label, value, color: barColor }) => (
              <div style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 11 }}>
                <div style={{ width: 90, color: C.textMuted, flexShrink: 0 }}>{label}</div>
                <div style={{ flex: 1, height: 8, background: C.bg3, borderRadius: 4, overflow: "hidden" }}>
                  <div style={{ width: Math.min(100, value) + "%", height: "100%", background: barColor || C.cyan, borderRadius: 4, transition: "width 0.5s" }}></div>
                </div>
                <div style={{ width: 32, textAlign: "right", fontWeight: 600, color: barColor || C.text, fontFamily: "'IBM Plex Mono',monospace" }}>{Math.round(value)}%</div>
              </div>
            );

            return (
              <React.Fragment key={race.id}>
              <Card style={{ borderColor: readinessColor + "40" }}>
                <div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 16 }}>
                  <div style={{ display: "flex", gap: 14, alignItems: "center" }}>
                    <RaceLogo name={race.name} size={52} />
                    <div>
                      <div style={{ fontSize: 16, fontWeight: 800, color: C.text, fontFamily: "'Space Grotesk',sans-serif" }}>{race.name}</div>
                      <div style={{ fontSize: 12, color: C.textMuted, marginTop: 2 }}>
                        {race.date} · {race.distance} {race.goalTime && <span>· Goal: <span style={{ color: C.amber, fontWeight: 600 }}>{race.goalTime}</span></span>}
                      </div>
                    </div>
                  </div>
                  <div style={{ textAlign: "right" }}>
                    <div style={{ fontSize: 28, fontWeight: 800, fontFamily: "'Space Grotesk',sans-serif", color: readinessColor }}>{daysAway}</div>
                    <div style={{ fontSize: 10, color: C.textMuted }}>days to go</div>
                  </div>
                </div>

                {/* Readiness hero — score + projected-vs-goal delta. The
                    previous version stacked four ScoreBars (Volume / Long
                    Run / Fitness / Consistency) anchored on whole-plan
                    targets, which duplicated the phase-specific bars
                    rendered just below in the End-of-Phase Goals panel
                    with different denominators (whole plan vs current
                    phase) and confused the read. For race prep the
                    actionable framing is "where am I against my goal?"
                    + "what does this phase demand?" — so we collapse the
                    top to a single hero and let the phase view carry the
                    detailed milestone progress. */}
                <div style={{ display: "flex", alignItems: "center", gap: 16, marginBottom: 16, padding: "12px 16px", background: C.bg2, borderRadius: 10 }}>
                  <div style={{ position: "relative", width: 70, height: 70, flexShrink: 0 }}>
                    <svg width="70" height="70" viewBox="0 0 70 70">
                      <circle cx="35" cy="35" r="30" fill="none" stroke={C.bg3} strokeWidth="6" />
                      <circle cx="35" cy="35" r="30" fill="none" stroke={readinessColor} strokeWidth="6"
                        strokeDasharray={`${overallReadiness * 1.885} 188.5`} strokeLinecap="round"
                        transform="rotate(-90 35 35)" />
                    </svg>
                    <div style={{ position: "absolute", top: "50%", left: "50%", transform: "translate(-50%,-50%)", fontSize: 18, fontWeight: 800, color: readinessColor, fontFamily: "'Space Grotesk',sans-serif" }}>{overallReadiness}</div>
                  </div>
                  <div style={{ flex: 1 }}>
                    <div style={{ fontSize: 14, fontWeight: 700, color: readinessColor, marginBottom: 2 }}>{readinessLabel}</div>
                    {(() => {
                      // One-line readiness narrative. Three cases:
                      //   1. No quality effort in 90d → don't fabricate
                      //      a projection from VO2max alone. Prompt for
                      //      a tempo/race effort instead.
                      //   2. Have a projection but no goal → just show
                      //      the projected time on its own.
                      //   3. Have both → show projection + goal + gap.
                      if (!recentQuality) {
                        return <div style={{ fontSize: 11, color: C.textMuted, lineHeight: 1.5 }}>
                          No race-effort run in last 90d — projection requires a recent tempo, race, or sustained quality effort.
                        </div>;
                      }
                      if (!adjustedProjectedSec || !goalSec) {
                        return <div style={{ fontSize: 11, color: C.textMuted, lineHeight: 1.5 }}>Phase progress and milestones below.</div>;
                      }
                      // Confidence bands — A/B/C goals with rough probability,
                      // computed from quality-effort variance + Riegel
                      // extrapolation + weeks-out uncertainty. Replaces the
                      // single "+1:05 to close" gap with the more honest
                      // "fitness floor 3:15 / realistic 3:05 / stretch 2:58"
                      // framing a coach actually uses.
                      const distMetersMap = { "5K": 5000, "10K": 10000, "half": 21097, "marathon": 42195 };
                      const targetMeters = distMetersMap[race.distance] || 42195;
                      const bestEffortM = recentQuality?.dist || null;
                      const weeksOut = Math.max(0, Math.ceil(daysAway / 7));
                      const bands = window.GoalConfidence.confidenceProjection({
                        pointEstimateSec: projectedSec, // pre-course-adjustment point estimate
                        targetMeters, bestEffortMeters: bestEffortM,
                        weeksOut, runs, courseAdjustSec: courseAdjSec || 0,
                        // Past races feed PR-anchoring so the bands don't
                        // underestimate proven capability.
                        pastRaces: (pastRaces || []).map(r => ({
                          distance: r.distance,
                          timeSec: r.timeSeconds || r.timeSec || (r.time && /^\d+:\d+(:\d+)?$/.test(r.time) ? r.time.split(":").reduce((a, b) => Number(a) * 60 + Number(b)) : null),
                          date: r.date,
                        })).filter(r => r.timeSec),
                      });
                      if (!bands) {
                        // Fall back to the original single-line format
                        const gap = adjustedProjectedSec - goalSec;
                        const ahead = gap < 0;
                        const gapAbs = Math.abs(gap);
                        const gapStr = gapAbs >= 60
                          ? `${Math.floor(gapAbs / 60)}:${String(Math.round(gapAbs % 60)).padStart(2, "0")}`
                          : `${Math.round(gapAbs)}s`;
                        const gapColor = ahead ? C.green : gap < 120 ? C.amber : C.red;
                        return (
                          <div style={{ fontSize: 11, color: C.textMuted, lineHeight: 1.6 }}>
                            Projected <span style={{ color: C.text, fontWeight: 600, fontFamily: "'IBM Plex Mono',monospace" }}>{fmt.duration(Math.round(adjustedProjectedSec))}</span>
                            <span style={{ margin: "0 6px" }}>·</span>
                            Goal <span style={{ color: C.amber, fontWeight: 600, fontFamily: "'IBM Plex Mono',monospace" }}>{fmt.duration(Math.round(goalSec))}</span>
                            <span style={{ margin: "0 6px" }}>·</span>
                            <span style={{ color: gapColor, fontWeight: 700 }}>{ahead ? `${gapStr} ahead` : `+${gapStr} to close`}</span>
                          </div>
                        );
                      }
                      // Render the A/B/C bands. Color each by whether
                      // it beats the goal (green) or trails (amber/red).
                      const bandColor = (sec) => sec <= goalSec ? C.green : (sec - goalSec) < 120 ? C.amber : C.red;
                      const fmtBand = (b) => (
                        <span key={b.label}>
                          <span style={{ color: C.textMuted }}>{b.label.split(" — ")[1]}</span>
                          {" "}
                          <span style={{ color: bandColor(b.sec), fontWeight: 700, fontFamily: "'IBM Plex Mono',monospace" }}>
                            {fmt.duration(b.sec)}
                          </span>
                          <span style={{ color: C.textMuted, fontSize: 10 }}> ({Math.round(b.prob * 100)}%)</span>
                        </span>
                      );
                      return (
                        <div style={{ fontSize: 11, color: C.textMuted, lineHeight: 1.6 }}>
                          Goal <span style={{ color: C.amber, fontWeight: 600, fontFamily: "'IBM Plex Mono',monospace" }}>{fmt.duration(Math.round(goalSec))}</span>
                          <span style={{ margin: "0 8px", color: C.border }}>|</span>
                          {fmtBand(bands.A)}
                          <span style={{ margin: "0 6px", color: C.border }}>·</span>
                          {fmtBand(bands.B)}
                          <span style={{ margin: "0 6px", color: C.border }}>·</span>
                          {fmtBand(bands.C)}
                          <div style={{ fontSize: 9, color: C.textMuted, marginTop: 2, fontStyle: "italic" }}>
                            Confidence bands from {bands.n} quality efforts · ±{Math.floor(bands.bandWidthSec/60)}:{String(bands.bandWidthSec%60).padStart(2,"0")} σ · {weeksOut}w out
                          </div>
                        </div>
                      );
                    })()}
                  </div>
                </div>
                {/* Key Metrics */}
                <div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 10, marginBottom: 14 }}>
                  <div style={{ textAlign: "center", padding: "10px 6px", background: C.bg2, borderRadius: 8 }}>
                    <div style={{ fontSize: 10, color: C.textMuted, marginBottom: 4 }}>Actual Avg</div>
                    <div style={{ fontSize: 16, fontWeight: 700, color: C.cyan, fontFamily: "'IBM Plex Mono',monospace" }}>{avgMilesLast4.toFixed(1)}</div>
                    <div style={{ fontSize: 10, color: C.textMuted }}>mi/wk · {Math.min(4, weeksWithRuns.length)}w avg</div>
                  </div>
                  <div style={{ textAlign: "center", padding: "10px 6px", background: C.bg2, borderRadius: 8 }}>
                    <div style={{ fontSize: 10, color: C.textMuted, marginBottom: 4 }}>Longest Run</div>
                    <div style={{ fontSize: 16, fontWeight: 700, color: C.green, fontFamily: "'IBM Plex Mono',monospace" }}>{longestRun.toFixed(1)}</div>
                    <div style={{ fontSize: 10, color: C.textMuted }}>mi</div>
                  </div>
                  <div style={{ textAlign: "center", padding: "10px 6px", background: C.bg2, borderRadius: 8 }}>
                    <div style={{ fontSize: 10, color: C.textMuted, marginBottom: 4 }}>Projected</div>
                    <div style={{ fontSize: 16, fontWeight: 700, color: adjustedProjectedSec ? C.amber : C.textMuted, fontFamily: "'IBM Plex Mono',monospace" }}
                      title={!recentQuality ? "Projection requires a recent race-effort run (race / tempo / interval / sustained sub-6:00)" : undefined}>
                      {adjustedProjectedSec ? fmt.duration(Math.round(adjustedProjectedSec)) : "—"}
                    </div>
                    <div style={{ fontSize: 10, color: C.textMuted }}>
                      {!recentQuality ? "no quality effort 90d" : (course ? "finish (course adj.)" : "finish")}
                    </div>
                  </div>
                  <div style={{ textAlign: "center", padding: "10px 6px", background: C.bg2, borderRadius: 8 }}>
                    <div style={{ fontSize: 10, color: C.textMuted, marginBottom: 4 }}>Target Pace</div>
                    <div style={{ fontSize: 16, fontWeight: 700, color: C.purple, fontFamily: "'IBM Plex Mono',monospace" }}>{goalPace ? fmt.pace(goalPace).split(" ")[0] : "--"}</div>
                    <div style={{ fontSize: 10, color: C.textMuted }}>/mi (certified)</div>
                    {gpsPace && (
                      <div style={{ marginTop: 4, fontSize: 12, fontWeight: 600, color: C.cyan, fontFamily: "'IBM Plex Mono',monospace" }}>
                        {fmt.pace(gpsPace).split(" ")[0]}<span style={{ fontSize: 10, color: C.textMuted, fontWeight: 400 }}> /mi (GPS)</span>
                      </div>
                    )}
                  </div>
                </div>

                {/* Phase Progress — milestone tracking for current training phase */}
                {activePhase && (
                  <div style={{marginBottom:14}}>
                    <div style={{display:"flex",gap:6,marginBottom:10}}>
                      {phases.map((p,pi) => (
                        <div key={pi} style={{
                          flex:1,padding:"8px 10px",borderRadius:8,textAlign:"center",cursor:"default",
                          background: p.active ? C.cyan+"15" : C.bg2,
                          border: `1px solid ${p.active ? C.cyan+"50" : C.border+"30"}`,
                          opacity: p.active ? 1 : 0.5,
                        }}>
                          <div style={{fontSize:10,fontWeight:700,color:p.active?C.cyan:C.textMuted}}>{p.name}</div>
                          <div style={{fontSize:9,color:C.textMuted}}>Wk {p.weeks}</div>
                        </div>
                      ))}
                    </div>
                    <Card style={{borderColor:C.cyan+"30",padding:"14px"}}>
                      <div style={{fontSize:13,fontWeight:700,color:C.cyan,marginBottom:4}}>
                        {activePhase.name} — End-of-Phase Goals
                      </div>
                      <div style={{fontSize:11,color:C.textMuted,marginBottom:12,lineHeight:1.5}}>
                        {activePhase.focus}
                      </div>
                      <div style={{display:"flex",flexDirection:"column",gap:10}}>
                        {activePhase.milestones.map((m,mi) => {
                          const pct = m.invert
                            ? Math.min(100, Math.max(0, m.current <= m.target ? 100 : (1 - (m.current - m.target) / m.target) * 100))
                            : Math.min(100, (m.current / m.target) * 100);
                          const pctColor = pct >= 80 ? C.green : pct >= 50 ? C.amber : C.red;
                          const displayCurrent = m.invert
                            ? (m.unit === "min" ? `${Math.floor(m.current/60)}:${String(m.current%60).padStart(2,"0")}` : m.current)
                            : (typeof m.current === "number" ? m.current.toFixed(1) : m.current);
                          const displayTarget = m.invert
                            ? (m.unit === "min" ? `${Math.floor(m.target/60)}:${String(m.target%60).padStart(2,"0")}` : m.target)
                            : m.target;
                          return (
                            <div key={mi}>
                              <div style={{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:3}}>
                                <div style={{fontSize:11,color:C.text,fontWeight:600}}>{m.label}</div>
                                <div style={{fontSize:11,fontFamily:"'IBM Plex Mono',monospace"}}>
                                  <span style={{color:pctColor,fontWeight:700}}>{displayCurrent}</span>
                                  <span style={{color:C.textMuted}}> / {displayTarget} {m.unit}</span>
                                </div>
                              </div>
                              <div style={{height:6,background:C.bg3,borderRadius:3,overflow:"hidden"}}>
                                <div style={{width:`${pct}%`,height:"100%",background:pctColor,borderRadius:3,transition:"width 0.5s"}} />
                              </div>
                              <div style={{fontSize:9,color:C.textMuted,marginTop:2}}>{m.desc}</div>
                            </div>
                          );
                        })}
                      </div>
                    </Card>
                  </div>
                )}

                {/* Insights */}
                {insights.length > 0 && (
                  <div style={{ display: "flex", flexDirection: "column", gap: 6, padding: "12px 14px", background: C.bg2, borderRadius: 8 }}>
                    <div style={{ fontSize: 11, fontWeight: 600, color: C.textDim, marginBottom: 2 }}>Insights</div>
                    {insights.map((ins, ii) => (
                      <div key={ii} style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 11 }}>
                        <span style={{ width: 6, height: 6, borderRadius: "50%", background: ins.color, flexShrink: 0 }}></span>
                        <span style={{ color: ins.color }}>{ins.text}</span>
                      </div>
                    ))}
                  </div>
                )}

                {/* Sub-3:00 Probability */}
                {sub3Prob && (
                  <Card style={{borderColor:(sub3Prob.prob >= 50 ? C.green : sub3Prob.prob >= 25 ? C.amber : C.red)+"40",padding:"14px"}}>
                    <div style={{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:12}}>
                      <div style={{fontSize:13,fontWeight:700,color:C.text}}>Sub-3:00 Probability</div>
                      <div style={{fontSize:28,fontWeight:800,fontFamily:"'Space Grotesk',sans-serif",
                        color:sub3Prob.prob >= 50 ? C.green : sub3Prob.prob >= 25 ? C.amber : C.red}}>
                        {sub3Prob.prob}%
                      </div>
                    </div>
                    <div style={{fontSize:11,color:C.textMuted,marginBottom:10}}>
                      Current projection: {adjustedProjectedSec ? fmt.duration(Math.round(adjustedProjectedSec)) : "--"} ({sub3Prob.gapMin > 0 ? `${sub3Prob.gapMin} min gap` : "under target"}) · GPS race distance: ~{sub3Prob.gpsRaceMi}mi
                    </div>
                    <div style={{display:"flex",flexDirection:"column",gap:8}}>
                      {sub3Prob.progress.map((p,i) => (
                        <div key={i} style={{padding:"6px 0",borderBottom:i < sub3Prob.progress.length - 1 ? `1px solid ${C.border}40` : "none"}}>
                          <div style={{display:"flex",justifyContent:"space-between",alignItems:"baseline",marginBottom:4,gap:8}}>
                            <span style={{fontSize:11,fontWeight:600,color:C.textDim}}>{p.label}</span>
                            <span style={{fontSize:11,fontFamily:"'IBM Plex Mono',monospace"}}>
                              <span style={{color:p.color,fontWeight:700}}>{p.value}</span>
                              <span style={{color:C.textMuted}}> / {p.target}</span>
                            </span>
                          </div>
                          <div style={{height:6,background:C.bg3,borderRadius:3,overflow:"hidden",marginBottom:3}}>
                            <div style={{width:`${p.pct}%`,height:"100%",background:p.color,borderRadius:3,transition:"width 0.5s"}} />
                          </div>
                          <div style={{fontSize:10,color:C.textMuted}}>{p.desc}</div>
                        </div>
                      ))}
                    </div>
                    <div style={{fontSize:10,color:C.textMuted,marginTop:8,lineHeight:1.5}}>
                      {sub3Prob.prob < 15 ? "Significant gap to close. Focus on building consistent volume (30+ mi/wk) before speed work." :
                       sub3Prob.prob < 35 ? "Achievable with dedicated training. Volume and long runs are the priority over the next 8-12 weeks." :
                       sub3Prob.prob < 60 ? "On track but needs sustained effort. Don't skip long runs and maintain 4-5 runs per week minimum." :
                       "Strong position. Execute the plan, stay healthy, and trust the process."}
                    </div>
                  </Card>
                )}

                {/* Course Strategy — only for known courses */}
                {course && (
                  <details style={{ marginBottom: 2 }}>
                    <summary style={{ fontSize: 11, fontWeight: 600, color: C.cyan, cursor: "pointer", padding: "8px 0" }}>
                      Course Strategy: {race.name} ({course.elevGainFt}ft elevation gain, {course.bridges.length} bridges)
                    </summary>
                    <div style={{ padding: "10px 14px", background: C.bg2, borderRadius: 8, display: "flex", flexDirection: "column", gap: 8 }}>
                      {course.bridges.map((b, bi) => (
                        <div key={bi} style={{ display: "flex", gap: 10, alignItems: "flex-start", fontSize: 11 }}>
                          <div style={{ minWidth: 60, color: C.textMuted, flexShrink: 0 }}>Mile {b.mile}</div>
                          <div>
                            <span style={{ fontWeight: 600, color: C.amber }}>{b.name}</span>
                            <span style={{ color: C.textMuted }}> (+{b.gainFt}ft)</span>
                            <div style={{ color: C.textDim, marginTop: 2 }}>{b.note}</div>
                          </div>
                        </div>
                      ))}
                      <div style={{ borderTop: `1px solid ${C.border}30`, paddingTop: 8, marginTop: 4 }}>
                        <div style={{ fontSize: 10, fontWeight: 600, color: C.textMuted, marginBottom: 6 }}>TRAINING RECOMMENDATIONS</div>
                        {course.trainingRecs.map((rec, ri) => (
                          <div key={ri} style={{ display: "flex", gap: 6, fontSize: 11, color: C.textDim, marginBottom: 4 }}>
                            <span style={{ color: C.cyan }}>→</span> {rec}
                          </div>
                        ))}
                      </div>
                    </div>
                  </details>
                )}

                {/* Overall Progress Chart — readiness % over time */}
                {(() => {
                  const raceDate = new Date(race.date);
                  const sixMonthsAgo = new Date(raceDate); sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 8);
                  const firstRunDate = runs.length > 0 ? (() => {
                    const dates = runs.map(r => r.start_date?.toDate ? r.start_date.toDate() : new Date(r.start_date));
                    return new Date(Math.min(...dates));
                  })() : sixMonthsAgo;
                  const trainingStart = new Date(Math.max(firstRunDate.getTime(), sixMonthsAgo.getTime()));
                  const totalWeeks = Math.ceil((raceDate - trainingStart) / (7*86400000));
                  const nowMs = Date.now();

                  // S-curve target trajectory (0→100%)
                  const k = 8;
                  const sCurveNorm = (pct) => {
                    const raw = 1 / (1 + Math.exp(-k * (pct - 0.45)));
                    const min = 1 / (1 + Math.exp(-k * (-0.45)));
                    const max = 1 / (1 + Math.exp(-k * (1 - 0.45)));
                    return (raw - min) / (max - min);
                  };

                  const progressData = [];
                  for (let w = 0; w <= totalWeeks; w++) {
                    const weekDate = new Date(trainingStart.getTime() + w * 7 * 86400000);
                    const weekLabel = weekDate.toLocaleDateString("en-US", { month: "short", day: "numeric" });
                    const pct = w / totalWeeks;
                    const targetPct = Math.round(sCurveNorm(pct) * 100);

                    // Compute actual readiness at this week from cumulative data up to this point
                    let actual = null;
                    if (weekDate <= new Date(nowMs + 86400000)) {
                      const wEnd = new Date(weekDate.getTime() + 7 * 86400000);
                      // 4-week rolling window for metrics
                      const w4Start = new Date(wEnd.getTime() - 28 * 86400000);
                      const windowRuns = runs.filter(r => {
                        const rd = r.start_date?.toDate ? r.start_date.toDate() : new Date(r.start_date);
                        return rd >= w4Start && rd < wEnd && r.distance > 0;
                      });

                      if (windowRuns.length > 0) {
                        // Volume: weekly avg miles vs target (race distance * 0.6/wk)
                        const totalMi = windowRuns.reduce((s,r) => s + r.distance/1609.34, 0);
                        const avgWeeklyMi = totalMi / 4;
                        const volScore = Math.min(100, (avgWeeklyMi / (raceDistMi * 0.6)) * 100);

                        // Long run: longest in window vs 75% race dist
                        const longest = Math.max(...windowRuns.map(r => r.distance/1609.34));
                        const lrScore = Math.min(100, (longest / (raceDistMi * 0.75)) * 100);

                        // Fitness: Riegel-projected pace vs goal
                        let fitScore = 50;
                        if (goalSec) {
                          const best = windowRuns.filter(r => r.distance > 1500 && r.moving_time > 0);
                          if (best.length > 0) {
                            const projTimes = best.map(r => r.moving_time * Math.pow(raceDist / r.distance, 1.06));
                            const bestProj = Math.min(...projTimes);
                            fitScore = Math.min(100, (goalSec / bestProj) * 100);
                          }
                        }

                        // Consistency: weeks with 3+ runs out of last 4
                        let consWeeks = 0;
                        for (let cw = 0; cw < 4; cw++) {
                          const cwStart = new Date(w4Start.getTime() + cw * 7 * 86400000);
                          const cwEnd = new Date(cwStart.getTime() + 7 * 86400000);
                          const cwRuns = runs.filter(r => {
                            const rd = r.start_date?.toDate ? r.start_date.toDate() : new Date(r.start_date);
                            return rd >= cwStart && rd < cwEnd;
                          });
                          if (cwRuns.length >= 3) consWeeks++;
                        }
                        const consScore = Math.min(100, (consWeeks / 4) * 100);

                        actual = Math.round(volScore * 0.25 + lrScore * 0.25 + fitScore * 0.3 + consScore * 0.2);
                      }
                    }

                    progressData.push({ week: weekLabel, target: targetPct, actual, isFuture: weekDate > new Date(nowMs) });
                  }

                  // Smooth actual with 3-week rolling avg
                  const smoothed = progressData.map((d, i) => {
                    if (d.actual === null) return { ...d, smoothed: null };
                    const win = [d.actual];
                    if (i > 0 && progressData[i-1].actual != null) win.push(progressData[i-1].actual);
                    if (i > 1 && progressData[i-2].actual != null) win.push(progressData[i-2].actual);
                    return { ...d, smoothed: Math.round(win.reduce((s,v)=>s+v,0) / win.length) };
                  });

                  const latestActual = smoothed.filter(d => d.smoothed != null).pop();
                  const currentPct = latestActual ? latestActual.smoothed : 0;
                  const latestTarget = latestActual ? smoothed[smoothed.indexOf(latestActual)]?.target : 0;
                  const delta = currentPct - latestTarget;
                  const statusColor = delta >= 0 ? C.green : delta >= -15 ? C.amber : C.red;
                  /* Status reflects "actual readiness vs the generic S-curve trajectory for today",
                     NOT "plan adherence". Following the plan should track near the curve, not exceed it. */
                  const statusText = delta >= 5 ? "Above trajectory" : delta >= -5 ? "On trajectory" : delta >= -15 ? "Slightly below" : "Below trajectory";

                  return (
                    <div style={{ marginTop: 14 }}>
                      <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 4 }}>
                        <div style={{ fontSize: 13, fontWeight: 600, color: C.textDim }}>Race Readiness</div>
                        <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
                          <span style={{ fontSize: 22, fontWeight: 800, fontFamily: "'Space Grotesk',sans-serif", color: statusColor }}>{currentPct}%</span>
                          <span style={{ fontSize: 10, color: C.textMuted, fontFamily: "'IBM Plex Mono',monospace" }}>vs {latestTarget}% target</span>
                          <span style={{ fontSize: 10, padding: "2px 8px", borderRadius: 4, background: statusColor + "20", color: statusColor }}>{statusText}</span>
                        </div>
                      </div>
                      <div style={{ fontSize: 10, color: C.textMuted, marginBottom: 10 }}>
                        100% = peak race fitness on race day. Score blends volume (25%), longest long run (25%), pace fitness (30%), and consistency (20%) from your actual training data. The dashed line is a generic readiness curve, not your plan.
                      </div>
                      <ResponsiveContainer width="100%" height={200}>
                        {/* ComposedChart, not AreaChart — Recharts'
                            AreaChart only natively renders Area
                            children, so the Line below was silently
                            being dropped. The orange target S-curve
                            now appears alongside the green Area. */}
                        <ComposedChart data={smoothed} margin={{ top: 5, right: 10, left: 10, bottom: 5 }}>
                          <defs>
                            <linearGradient id={`progressGrad_${race.id}`} x1="0" y1="0" x2="0" y2="1">
                              <stop offset="5%" stopColor={C.green} stopOpacity={0.25}/>
                              <stop offset="95%" stopColor={C.green} stopOpacity={0.02}/>
                            </linearGradient>
                          </defs>
                          <CartesianGrid strokeDasharray="3 3" stroke={C.border} vertical={false} />
                          <XAxis dataKey="week" tick={{ fill: C.textMuted, fontSize: 9 }} axisLine={false} tickLine={false} interval="preserveStartEnd" />
                          <YAxis domain={[0, 105]} tick={{ fill: C.textMuted, fontSize: 10 }} tickFormatter={v => v + "%"} axisLine={false} tickLine={false} width={38} />
                          <Tooltip content={({ active, payload, label }) => {
                            if (!active || !payload || !payload.length) return null;
                            return (
                              <div style={{ background: C.bg, border: `1px solid ${C.border}`, borderRadius: 8, padding: "8px 12px", fontSize: 11 }}>
                                <div style={{ fontWeight: 600, color: C.text, marginBottom: 4 }}>{label}</div>
                                {payload.map((p, i) => p.value != null && (
                                  <div key={i} style={{ color: p.color, display: "flex", gap: 8, justifyContent: "space-between" }}>
                                    <span>{p.name}:</span>
                                    <span style={{ fontWeight: 700, fontFamily: "'IBM Plex Mono',monospace" }}>{p.value}%</span>
                                  </div>
                                ))}
                              </div>
                            );
                          }} />
                          <ReferenceLine y={100} stroke={C.green + "30"} strokeDasharray="4 4" label={{ value: "Race Ready", fill: C.green, fontSize: 9, position: "right" }} />
                          <Area type="monotone" dataKey="smoothed" stroke={C.green} fill={`url(#progressGrad_${race.id})`} strokeWidth={2.5} dot={{ r: 3, fill: C.green }} connectNulls={false} name="Actual" />
                          <Line type="monotone" dataKey="target" stroke={C.amber} strokeWidth={2} strokeDasharray="6 4" dot={false} name="Target" />
                        </ComposedChart>
                      </ResponsiveContainer>
                      <div style={{ display: "flex", gap: 20, justifyContent: "center", marginTop: 8, fontSize: 10 }}>
                        <span style={{ display: "flex", alignItems: "center", gap: 6 }}>
                          <span style={{ width: 20, height: 2, background: C.amber, display: "inline-block", borderTop: "1px dashed " + C.amber }}></span>
                          <span style={{ color: C.textMuted }}>Target (S-curve)</span>
                        </span>
                        <span style={{ display: "flex", alignItems: "center", gap: 6 }}>
                          <span style={{ width: 20, height: 3, background: C.green, display: "inline-block", borderRadius: 2 }}></span>
                          <span style={{ color: C.textMuted }}>Actual readiness</span>
                        </span>
                      </div>
                    </div>
                  );
                })()}

                {/* Plan Compliance — prescribed weekly mileage vs actual logged miles.
                    Concrete grounded-in-miles companion to the readiness chart, since
                    "% of plan" only makes sense when both numerator and denominator
                    come from the actual prescribed plan (not a generic S-curve). */}
                {activePlan?.weeks?.length > 0 && (() => {
                  // All week-date math + actual-mileage credit goes
                  // through PlanMath so this chart, the Weekly Mileage
                  // chart, and per-week cards all agree on (a) which
                  // calendar Monday a given plan week starts on and
                  // (b) which runs count toward each week's total.
                  if (!window.PlanMath.planWeek1Start(activePlan)) return null;
                  const nowMs = Date.now();

                  const data = activePlan.weeks.map((week, wi) => {
                    const range = window.PlanMath.weekDateRange(activePlan, wi);
                    const wkStart = range.start;
                    const wkEnd = range.end;
                    const wkLabel = wkStart.toLocaleDateString("en-US", { month: "short", day: "numeric" });
                    const prescribed = Math.round(window.PlanMath.plannedMiForWeek(week));
                    const isFuture = wkStart > new Date(nowMs);
                    const isCurrent = wkStart <= new Date(nowMs) && wkEnd > new Date(nowMs);
                    const actual = isFuture
                      ? null
                      : Math.round(window.PlanMath.actualMiForWeek(activePlan, wi, activities) * 10) / 10;
                    return { week: wkLabel, prescribed, actual, isCurrent, isFuture };
                  });

                  /* Compliance summary: only count fully-completed weeks (skip the
                     in-progress one to avoid scoring against a partial number). Cap
                     a single week at 120% so one heroic over-mileage week doesn't
                     mask under-execution elsewhere. */
                  const completed = data.filter(d => !d.isCurrent && !d.isFuture && d.actual != null && d.prescribed > 0);
                  const compliancePct = completed.length > 0
                    ? Math.round(completed.reduce((s, d) => s + Math.min(120, (d.actual / d.prescribed) * 100), 0) / completed.length)
                    : null;
                  const compColor = compliancePct == null ? C.textMuted : compliancePct >= 90 ? C.green : compliancePct >= 70 ? C.amber : C.red;
                  const compText = compliancePct == null ? "" : compliancePct >= 90 ? "On plan" : compliancePct >= 70 ? "Mostly on plan" : "Behind plan";

                  return (
                    <div style={{ marginTop: 24 }}>
                      <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 4 }}>
                        <div style={{ fontSize: 13, fontWeight: 600, color: C.textDim }}>Plan Compliance</div>
                        {compliancePct != null && (
                          <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
                            <span style={{ fontSize: 22, fontWeight: 800, fontFamily: "'Space Grotesk',sans-serif", color: compColor }}>{compliancePct}%</span>
                            <span style={{ fontSize: 10, padding: "2px 8px", borderRadius: 4, background: compColor + "20", color: compColor }}>{compText}</span>
                          </div>
                        )}
                      </div>
                      <div style={{ fontSize: 10, color: C.textMuted, marginBottom: 10 }}>
                        Bars = prescribed mileage from your plan. Line = actual logged miles. 100% = doing the plan exactly. Skips the in-progress week.
                      </div>
                      <ResponsiveContainer width="100%" height={200}>
                        <ComposedChart data={data} margin={{ top: 5, right: 10, left: 10, bottom: 5 }}>
                          <CartesianGrid strokeDasharray="3 3" stroke={C.border} vertical={false} />
                          <XAxis dataKey="week" tick={{ fill: C.textMuted, fontSize: 9 }} axisLine={false} tickLine={false} interval="preserveStartEnd" />
                          <YAxis tick={{ fill: C.textMuted, fontSize: 10 }} tickFormatter={v => v + "mi"} axisLine={false} tickLine={false} width={42} />
                          <Tooltip content={({ active, payload, label }) => {
                            if (!active || !payload || !payload.length) return null;
                            const p = data.find(d => d.week === label);
                            const pct = p && p.prescribed > 0 && p.actual != null ? Math.round((p.actual / p.prescribed) * 100) : null;
                            return (
                              <div style={{ background: C.bg, border: `1px solid ${C.border}`, borderRadius: 8, padding: "8px 12px", fontSize: 11 }}>
                                <div style={{ fontWeight: 600, color: C.text, marginBottom: 4 }}>{label}{p?.isCurrent ? " (this week)" : ""}</div>
                                <div style={{ color: C.textDim, fontFamily: "'IBM Plex Mono',monospace" }}>Prescribed: {p?.prescribed ?? 0}mi</div>
                                {p?.actual != null && (
                                  <div style={{ color: C.green, fontFamily: "'IBM Plex Mono',monospace" }}>
                                    Actual: {p.actual.toFixed(1)}mi {pct != null ? `(${pct}%)` : ""}
                                  </div>
                                )}
                              </div>
                            );
                          }} />
                          <Bar dataKey="prescribed" fill={C.cyan + "30"} stroke={C.cyan + "60"} strokeWidth={1} />
                          <Line type="monotone" dataKey="actual" stroke={C.green} strokeWidth={2} dot={{ r: 3, fill: C.green }} connectNulls={false} isAnimationActive={false} />
                        </ComposedChart>
                      </ResponsiveContainer>
                      <div style={{ display: "flex", justifyContent: "center", gap: 16, marginTop: 8, fontSize: 10 }}>
                        <span style={{ display: "flex", alignItems: "center", gap: 6 }}>
                          <span style={{ width: 14, height: 8, background: C.cyan + "30", border: `1px solid ${C.cyan + "60"}`, display: "inline-block" }}></span>
                          <span style={{ color: C.textMuted }}>Prescribed (plan)</span>
                        </span>
                        <span style={{ display: "flex", alignItems: "center", gap: 6 }}>
                          <span style={{ width: 20, height: 3, background: C.green, display: "inline-block", borderRadius: 2 }}></span>
                          <span style={{ color: C.textMuted }}>Actual (logged)</span>
                        </span>
                      </div>
                    </div>
                  );
                })()}
              </Card>
              {/* Curated course profile + per-mile pacing strategy for
                  this race (renders only if the race name matches a
                  catalog entry: NYC, Boston, Chicago, London, Berlin,
                  Tokyo). Mirrors the card on the Summary tab. */}
              <RaceCourseProfileCard
                raceName={race.name}
                raceLocation={race.location}
                goalTime={effGoal(race)}
              />
              <GoalEvidenceCard race={race} goalTime={effGoal(race)} activities={activities} />
              <RaceFuelingCard race={race} goalTime={effGoal(race)} activities={activities} />
              </React.Fragment>
            );
          })}

          {/* Add race form */}
          {showAdd && (
            <Card style={{borderColor:C.cyanMuted}}>
              <div style={{fontSize:14,fontWeight:700,color:C.text,marginBottom:16}}>Add Race</div>
              <div style={{display:"grid",gridTemplateColumns:"1fr 1fr",gap:12}}>
                {[
                  {key:"name",label:"Race Name",type:"text",placeholder:"Boston Marathon"},
                  {key:"date",label:"Date",type:"date"},
                ].map(f => (
                  <div key={f.key}>
                    <div style={{fontSize:11,color:C.textMuted,marginBottom:6}}>{f.label}</div>
                    <input type={f.type} placeholder={f.placeholder} value={form[f.key]}
                      onChange={e => setForm({...form,[f.key]:e.target.value})}
                      style={{width:"100%",background:C.bg2,border:`1px solid ${C.border}`,borderRadius:6,
                        padding:"8px 12px",color:C.text,fontSize:13,fontFamily:"'IBM Plex Mono',monospace"}} />
                  </div>
                ))}
                <div>
                  <div style={{fontSize:11,color:C.textMuted,marginBottom:6}}>Distance</div>
                  <select value={form.distance} onChange={e => setForm({...form,distance:e.target.value})}
                    style={{width:"100%",background:C.bg2,border:`1px solid ${C.border}`,borderRadius:6,
                      padding:"8px 12px",color:C.text,fontSize:13,fontFamily:"'IBM Plex Mono',monospace"}}>
                    {["5K","10K","half","marathon","ultra","other"].map(d => <option key={d} value={d}>{d}</option>)}
                  </select>
                </div>
                <div>
                  <div style={{fontSize:11,color:C.textMuted,marginBottom:6}}>Goal Time</div>
                  <input type="text" placeholder="3:00:00" value={form.goalTime}
                    onChange={e => setForm({...form,goalTime:e.target.value})}
                    style={{width:"100%",background:C.bg2,border:`1px solid ${C.border}`,borderRadius:6,
                      padding:"8px 12px",color:C.text,fontSize:13,fontFamily:"'IBM Plex Mono',monospace"}} />
                </div>
                <div>
                  <div style={{fontSize:11,color:C.textMuted,marginBottom:6}}>Target Pace (per mile)</div>
                  <input type="text" placeholder="6:52" value={form.targetPace}
                    onChange={e => setForm({...form,targetPace:e.target.value})}
                    style={{width:"100%",background:C.bg2,border:`1px solid ${C.border}`,borderRadius:6,
                      padding:"8px 12px",color:C.text,fontSize:13,fontFamily:"'IBM Plex Mono',monospace"}} />
                  <div style={{fontSize:10,color:C.textMuted,marginTop:3}}>mm:ss — auto-calculated from goal time if left blank</div>
                </div>
              </div>
              <div style={{marginTop:12}}>
                <div style={{fontSize:11,color:C.textMuted,marginBottom:6}}>Notes</div>
                <textarea value={form.notes} onChange={e => setForm({...form,notes:e.target.value})}
                  placeholder="Course details, goals, race strategy..."
                  style={{width:"100%",background:C.bg2,border:`1px solid ${C.border}`,borderRadius:6,
                    padding:"8px 12px",color:C.text,fontSize:13,fontFamily:"'IBM Plex Mono',monospace",
                    height:80,resize:"vertical"}} />
              </div>
              <div style={{display:"flex",gap:8,marginTop:16}}>
                <Btn onClick={addRace}>Save Race</Btn>
                <Btn onClick={() => setShowAdd(false)} variant="ghost">Cancel</Btn>
              </div>
            </Card>
          )}

          {/* Upcoming races */}
          {upcomingRaces.length > 0 && (
            <Card>
              <div style={{fontSize:13,fontWeight:600,color:C.textDim,marginBottom:16}}>Upcoming Races</div>
              <div style={{display:"flex",flexDirection:"column",gap:12}}>
                {upcomingRaces.map(r => {
                  const daysAway = Math.ceil((new Date(r.date) - new Date()) / (1000*60*60*24));
                  const isEditing = editingRace === r.id;

                  if (isEditing) return (
                    <div key={r.id} style={{padding:"14px 16px",background:C.bg2,borderRadius:10,border:`1px solid ${C.cyan}40`}}>
                      <div style={{display:"grid",gridTemplateColumns:"1fr 1fr",gap:10}}>
                        <div>
                          <div style={{fontSize:10,color:C.textMuted,marginBottom:4}}>Race Name</div>
                          <input type="text" value={editForm.name} onChange={e => setEditForm({...editForm, name:e.target.value})}
                            style={{width:"100%",padding:"6px 10px",fontSize:12}} />
                        </div>
                        <div>
                          <div style={{fontSize:10,color:C.textMuted,marginBottom:4}}>Date</div>
                          <input type="date" value={editForm.date} onChange={e => setEditForm({...editForm, date:e.target.value})}
                            style={{width:"100%",padding:"6px 10px",fontSize:12}} />
                        </div>
                        <div>
                          <div style={{fontSize:10,color:C.textMuted,marginBottom:4}}>Distance</div>
                          <select value={editForm.distance} onChange={e => setEditForm({...editForm, distance:e.target.value})}
                            style={{width:"100%",padding:"6px 10px",fontSize:12}}>
                            {["5K","10K","half","marathon","ultra","other"].map(d => <option key={d} value={d}>{d}</option>)}
                          </select>
                        </div>
                        <div>
                          <div style={{fontSize:10,color:C.textMuted,marginBottom:4}}>Goal Time</div>
                          <input type="text" placeholder="3:00:00" value={editForm.goalTime} onChange={e => setEditForm({...editForm, goalTime:e.target.value})}
                            style={{width:"100%",padding:"6px 10px",fontSize:12}} />
                        </div>
                        <div>
                          <div style={{fontSize:10,color:C.textMuted,marginBottom:4}}>Target Pace</div>
                          <input type="text" placeholder="6:52" value={editForm.targetPace} onChange={e => setEditForm({...editForm, targetPace:e.target.value})}
                            style={{width:"100%",padding:"6px 10px",fontSize:12}} />
                        </div>
                      </div>
                      {/* Actual time — for past races, the finish you
                          actually ran. Two-or-more historical results
                          let us calibrate your personal Riegel
                          exponent (predictions then fit YOU instead
                          of generic 1.06). */}
                      <div style={{marginTop:8}}>
                        <div style={{fontSize:10,color:C.textMuted,marginBottom:4}}>
                          Actual Time <span style={{color:C.textMuted}}>(if completed — calibrates predictions)</span>
                        </div>
                        <input type="text" placeholder="3:02:18" value={editForm.actualTime || ""} onChange={e => setEditForm({...editForm, actualTime:e.target.value})}
                          style={{width:"100%",padding:"6px 10px",fontSize:12}} />
                      </div>
                      <div style={{marginTop:8}}>
                        <div style={{fontSize:10,color:C.textMuted,marginBottom:4}}>Notes</div>
                        <textarea value={editForm.notes} onChange={e => setEditForm({...editForm, notes:e.target.value})}
                          placeholder="Course details, goals, race strategy..."
                          style={{width:"100%",padding:"6px 10px",fontSize:12,height:60,resize:"vertical"}} />
                      </div>
                      <div style={{display:"flex",gap:8,marginTop:10}}>
                        <Btn onClick={updateRace}>Save</Btn>
                        <Btn onClick={() => setEditingRace(null)} variant="ghost">Cancel</Btn>
                      </div>
                    </div>
                  );

                  return (
                    <div key={r.id} style={{display:"flex",justifyContent:"space-between",alignItems:"center",
                      padding:"14px 16px",background:C.bg2,borderRadius:10,border:`1px solid ${C.border}`}}>
                      <div style={{display:"flex",gap:12,alignItems:"center",flex:1}}>
                        <RaceLogo name={r.name} size={40} />
                        <div>
                          <div style={{fontSize:14,fontWeight:700,color:C.text}}>{r.name}</div>
                          <div style={{fontSize:12,color:C.textMuted,marginTop:2}}>
                            {r.date} · {r.distance}
                            {effGoal(r) && <span style={{color:C.amber}}> · Goal: {effGoal(r)}
                              {isPlanRace(r) && activePlan?.goalTime && activePlan.goalTime !== r.goalTime &&
                                <span style={{color:C.textMuted}}> (from active plan)</span>}
                            </span>}
                            {r.targetPace && <span style={{color:C.green}}> · Pace: {r.targetPace}/mi</span>}
                          </div>
                          {r.notes && <div style={{fontSize:11,color:C.textMuted,marginTop:4,fontStyle:"italic"}}>{r.notes}</div>}
                        </div>
                      </div>
                      <div style={{display:"flex",alignItems:"center",gap:12,flexShrink:0,marginLeft:16}}>
                        <div style={{textAlign:"right"}}>
                          <div style={{fontSize:22,fontWeight:800,fontFamily:"'Space Grotesk', sans-serif",color:C.cyan}}>{daysAway}</div>
                          <div style={{fontSize:11,color:C.textMuted}}>days away</div>
                        </div>
                        {isOwner && <div style={{display:"flex",flexDirection:"column",gap:4}}>
                          <button onClick={() => startEdit(r)} style={{background:"transparent",border:`1px solid ${C.border}`,
                            borderRadius:5,padding:"4px 8px",color:C.cyan,fontSize:10,cursor:"pointer",
                            fontFamily:"'IBM Plex Mono',monospace"}}>Edit</button>
                          <button onClick={() => deleteRace(r.id)} style={{background:"transparent",border:`1px solid ${C.red}40`,
                            borderRadius:5,padding:"4px 8px",color:C.red,fontSize:10,cursor:"pointer",
                            fontFamily:"'IBM Plex Mono',monospace"}}>Del</button>
                        </div>}
                      </div>
                    </div>
                  );
                })}
              </div>
            </Card>
          )}

          {races.length === 0 && (
            <EmptyState icon="🏅" title="No races added" sub="Add your target races to track your training" />
          )}
        </div>
      );
    }

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