// running/src/summary.jsx — slice 19 of the module split, the last view.
//
// SUMMARY (Runalyze-style performance overview): the original mega-view
// — fitness & fatigue, race projections + Monte Carlo bands, LT history,
// training distribution, recovery, workout feedback, weather banner.
// Extracted verbatim from running/index.html with its four Summary-only
// helper groups (calcLTHistory · estimateRecoveryHours /
// classifyTrainingEffect · weatherPaceAdvice / generateWorkoutFeedback).
// Everything else comes from the UMD libs and the __appHelpers bridge —
// the whole point of the unification work that preceded this slice.

const { C, Card, Stat, Badge, ChartTooltip, fmt, gradeColor, metricColor, NutritionResetControl } = window.SharedUI;
const useData = () => (window.__appUseData ? window.__appUseData() : null);
const calcACRatio = (...a) => window.__appHelpers.calcACRatio(...a);
const calcMarathonShape = (...a) => window.__appHelpers.calcMarathonShape(...a);
const calcWeeklyMetrics = (...a) => window.__appHelpers.calcWeeklyMetrics(...a);
const calcTrainingDistribution = (...a) => window.__appHelpers.calcTrainingDistribution(...a);
const planGoalMarathonPaceSec = (...a) => window.__appHelpers.planGoalMarathonPaceSec(...a);
const { actDisplay, actCat, isRun } = window.ActivityTypes;
const { getHRZones, HR_METHODOLOGIES, classifyHRZone, paceZoneToHRIndex } = window.HRZones;
const {
  estimateVO2max, classifyRunIntent, calcEffectiveVO2max, bestRecentQualityEffort,
  danielsPredict, predictFromRace, predictRaceTimes, calcTrainingPaces, classifyPaceZone,
  monteCarloRaceProjections,
} = window.RaceMath;
const { estimateLactateThresholds } = window.LTEstimator;
const { useState, useEffect, useMemo } = React;
const {
  AreaChart, Area, LineChart, Line, BarChart, Bar, ComposedChart, Cell,
  XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
  ReferenceLine,
} = Recharts;

    // Calculate LT estimates over time. For each weekly anchor we use a
    // trailing 90-day window of runs to compute VO2max + run estimateLT
    // — this way a low-volume week with no quality efforts still inherits
    // the rolling fitness state (otherwise the chart would skip those
    // weeks or show wildly noisy single-run anchors). The empirical
    // HR-band match also benefits from the wider pool: a single week
    // rarely has a sustained tempo at LT2 HR, but the trailing 90d
    // almost always does. userLTHR threads through to `estimateLT`.
    function calcLTHistory(runs, maxHR, startDate, userLTHR, healthEntries, restHR) {
      if (!runs.length || !maxHR) return [];
      const cutoff = startDate || "2026-03-24";
      const cutoffMs = new Date(cutoff).getTime();
      const allSorted = runs
        .filter(r => r.distance > 1500)
        .map(r => {
          const d = r.start_date?.toDate ? r.start_date.toDate() : new Date(r.start_date);
          return { ...r, _ms: d.getTime(), _iso: d.toISOString().split("T")[0] };
        })
        .sort((a, b) => a._ms - b._ms);

      // Build the set of week-Monday anchors between cutoff and the
      // latest run's date.
      const lastMs = allSorted.length ? allSorted[allSorted.length - 1]._ms : Date.now();
      const anchors = [];
      let m = new Date(cutoffMs);
      m.setDate(m.getDate() - ((m.getDay() + 6) % 7));
      m.setHours(0, 0, 0, 0);
      while (m.getTime() <= lastMs) {
        anchors.push(new Date(m));
        m = new Date(m.getTime() + 7 * 86400000);
      }

      // Garmin Firstbeat VO2max per week (on-or-before each Monday) — the
      // canonical source. Calc-from-quality-runs is only the fallback when
      // no Garmin reading exists at that point in time. Mirrors the
      // Race Time Projections card so the chart's latest dot lands on the
      // card's "Current" value.
      const garminVO2sorted = (healthEntries || [])
        .filter(h => h?.garminVO2max > 30 && h.date)
        .sort((a, b) => (a.date || "").localeCompare(b.date || ""));
      const garminVO2AsOf = (iso) => {
        let best = null;
        for (const h of garminVO2sorted) {
          if (h.date <= iso) best = h.garminVO2max; else break;
        }
        return best;
      };

      return anchors.map(asOf => {
        const windowStart = asOf.getTime() - 90 * 86400000;
        const windowRuns = allSorted.filter(r => r._ms >= windowStart && r._ms <= asOf.getTime());
        if (!windowRuns.length) return null;
        const iso = asOf.toISOString().split("T")[0];
        const garminVal = garminVO2AsOf(iso);
        const vo2est = garminVal || calcEffectiveVO2max(windowRuns, 90, asOf);
        if (!vo2est) return null;
        const lt = estimateLactateThresholds(vo2est, maxHR, restHR || 49, windowRuns, null, userLTHR);
        if (!lt) return null;
        const label = asOf.toLocaleDateString("en-US", { month: "short", day: "numeric" });
        return {
          week: iso, label,
          vo2max: parseFloat(vo2est.toFixed(1)),
          source: garminVal ? "garmin" : "calc",
          lt1HR: lt.lt1.hr, lt2HR: lt.lt2.hr,
          lt1Pace: lt.lt1.pace, lt2Pace: lt.lt2.pace,
        };
      }).filter(Boolean);
    }

    // ── Recovery Time Estimation (Banister impulse-response model) ──────────
    // TRIMP via the shared engine (TrainingLoad.sessionTrimp — modality
    // weights + HR-reserve, athleteProfile-parameterized); the recovery
    // heuristics layered on top are web-only display enrichment.
    function estimateRecoveryHours(activity, recentActivities, hrCfg) {
      if (!activity) return null;
      const TL = window.TrainingLoad;
      const hrOpts = {
        maxHR: (hrCfg && hrCfg.maxHR) || 176,
        restHR: (hrCfg && (hrCfg.restHR != null ? hrCfg.restHR : hrCfg.restingHR)) || 49,
      };
      const trimp = TL.sessionTrimp(activity, hrOpts);
      // Base recovery: ~1 hour per 10 TRIMP points
      let baseHours = trimp / 10 * 1.0;
      // Adjust for training load context
      const last7 = recentActivities.filter(a => {
        const d = a.start_date?.toDate ? a.start_date.toDate() : new Date(a.start_date);
        return (Date.now() - d) < 7 * 86400000;
      });
      const weekTRIMP = last7.reduce((s, a) => s + TL.sessionTrimp(a, hrOpts), 0);
      // Higher cumulative load = longer recovery
      if (weekTRIMP > 400) baseHours *= 1.4;
      else if (weekTRIMP > 250) baseHours *= 1.2;
      // Distance factor for runs
      if (isRun(activity) && activity.distance > 25000) baseHours *= 1.5; // long runs need more
      else if (isRun(activity) && activity.distance > 16000) baseHours *= 1.2;
      // Intensity factor from HR
      if (activity.average_heartrate) {
        const actMaxHR = (hrCfg && hrCfg.maxHR) || activity.max_heartrate;
        const hrPct = actMaxHR ? activity.average_heartrate / actMaxHR : 0.75;
        if (hrPct > 0.9) baseHours *= 1.5;
        else if (hrPct > 0.8) baseHours *= 1.2;
      }
      return Math.max(6, Math.min(72, Math.round(baseHours)));
    }

    // ── Training Effect Classification ──────────────────────────────────────
    function classifyTrainingEffect(activity, configMaxHR) {
      if (!activity || !activity.moving_time) return { aerobic: 0, anaerobic: 0, label: "None" };
      const durationMin = activity.moving_time / 60;
      const avgHR = activity.average_heartrate || 0;
      const maxHR = configMaxHR || activity.max_heartrate;
      const hrPct = maxHR ? avgHR / maxHR : 0.75;

      // Aerobic effect: favors longer, moderate-intensity work
      let aerobic = Math.min(5, (durationMin / 20) * hrPct * 1.2);
      // Anaerobic effect: favors high-intensity work
      let anaerobic = hrPct > 0.85 ? Math.min(5, (hrPct - 0.75) * 10 * (durationMin / 30)) : Math.min(1.5, hrPct * 0.8);
      // Cap at 5.0
      aerobic = Math.min(5, Math.max(0, parseFloat(aerobic.toFixed(1))));
      anaerobic = Math.min(5, Math.max(0, parseFloat(anaerobic.toFixed(1))));

      let label = "Minor";
      if (aerobic >= 4 || anaerobic >= 4) label = "Highly Impactful";
      else if (aerobic >= 3 || anaerobic >= 3) label = "Impactful";
      else if (aerobic >= 2) label = "Maintaining";
      else if (aerobic >= 1) label = "Minor";

      return { aerobic, anaerobic, label };
    }

    // ── Weather & pace impact helpers ──────────────────────────────────────
    function weatherPaceAdvice(weather, health, hrZoneConfig, healthEntries) {
      if (!weather || weather.error) return null;
      const w = weather.current || {};
      const lines = [];
      const alerts = [];

      // Weather-based pace adjustment
      if (weather.paceAdjustSec > 0) {
        const mins = Math.floor(weather.paceAdjustSec / 60);
        const secs = weather.paceAdjustSec % 60;
        const adj = mins > 0 ? `${mins}:${String(secs).padStart(2,"0")}` : `${secs}s`;
        lines.push({ icon: weather.heatRisk === "cold" ? "+" : "+", text: `Adjust pace by +${adj}/mi for ${weather.heatRisk === "cold" ? "cold" : "heat"}`, severity: weather.heatRisk === "extreme" ? "red" : weather.heatRisk === "high" ? "amber" : "info" });
      }
      // Humidity warning
      if (w.humidity > 75 && w.tempF > 65) {
        alerts.push({ text: `High humidity (${w.humidity}%) — expect higher HR for same effort`, severity: "amber" });
      }
      // Wind advisory
      if (w.windMph > 15) {
        alerts.push({ text: `Wind ${w.windMph} mph — plan loops to finish with a tailwind`, severity: "info" });
      }
      // UV warning
      if (w.uvIndex >= 7) {
        alerts.push({ text: `UV Index ${w.uvIndex} — sunscreen and hat recommended`, severity: "amber" });
      }
      // Rain
      if (w.precipMM > 0.5) {
        alerts.push({ text: "Rain expected — trail shoes if off-road, shorter stride on pavement", severity: "info" });
      }

      // Health-based adjustments
      if (health) {
        // HRV — the personal-baseline daily call from the shared
        // HrvGuidance lib (same Kiviniemi/Javaloyes rule the Status page
        // and snapshot use), replacing absolute 35/50ms thresholds that
        // could contradict the banded verdict shown one panel over.
        const hrvG = (window.HrvGuidance && healthEntries && healthEntries.length)
          ? window.HrvGuidance.hrvGuidanceToday(healthEntries) : null;
        if (hrvG && hrvG.action !== "proceed") {
          const sev = hrvG.action === "caution" ? "amber" : "red";
          alerts.push({ text: `HRV: ${hrvG.reason}`, severity: sev });
        }
        if (health.restingHR != null && health.restingHR > 70) {
          alerts.push({ text: `Elevated resting HR (${health.restingHR} bpm) — body may be recovering`, severity: "amber" });
        }
        if (health.bodyBattery != null && health.bodyBattery < 25) {
          alerts.push({ text: `Low Body Battery (${health.bodyBattery}) — skip hard workout, recovery only`, severity: "red" });
        } else if (health.bodyBattery != null && health.bodyBattery < 50) {
          alerts.push({ text: `Moderate Body Battery (${health.bodyBattery}) — easy to moderate effort`, severity: "amber" });
        }
        if (health.sleepScore != null && health.sleepScore < 60) {
          alerts.push({ text: `Poor sleep (${health.sleepScore}/100) — reduce intensity today`, severity: "amber" });
        }
        if (health.stressAvg != null && health.stressAvg > 50) {
          alerts.push({ text: `High stress level (${health.stressAvg}) — prioritize easy aerobic work`, severity: "amber" });
        }
      }

      // Morning vs evening recommendation
      if (weather.morning && weather.evening) {
        const morningScore = (weather.morning.tempF < 75 ? 2 : 0) + (weather.morning.humidity < 70 ? 1 : 0) + (weather.morning.precipChance < 30 ? 1 : 0);
        const eveningScore = (weather.evening.tempF < 75 ? 2 : 0) + (weather.evening.humidity < 70 ? 1 : 0) + (weather.evening.precipChance < 30 ? 1 : 0);
        if (morningScore > eveningScore + 1) {
          lines.push({ icon: "AM", text: `Morning is better: ${weather.morning.tempF}F/${weather.morning.humidity}% vs evening ${weather.evening.tempF}F/${weather.evening.humidity}%`, severity: "info" });
        } else if (eveningScore > morningScore + 1) {
          lines.push({ icon: "PM", text: `Evening is better: ${weather.evening.tempF}F/${weather.evening.humidity}% vs morning ${weather.morning.tempF}F/${weather.morning.humidity}%`, severity: "info" });
        }
      }

      return { lines, alerts, weather: w, advice: weather.runAdvice };
    }

    // ── Workout Feedback Generator ──────────────────────────────────────
    function generateWorkoutFeedback(activity, activities, healthData, hrZoneConfig) {
      const feedback = [];
      if (!activity || !activity.moving_time) return feedback;

      const isRunActivity = isRun(activity);
      const d = activity.start_date?.toDate ? activity.start_date.toDate() : new Date(activity.start_date);
      const distMi = activity.distance ? activity.distance / 1609.34 : 0;
      const paceSecPerMi = isRunActivity && distMi > 0 ? activity.moving_time / distMi : 0;

      // Compare to recent similar activities
      const recentSimilar = activities.filter(a => {
        if (a === activity) return false;
        const ad = a.start_date?.toDate ? a.start_date.toDate() : new Date(a.start_date);
        const daysAgo = (d - ad) / (1000*60*60*24);
        return daysAgo > 0 && daysAgo < 30 && actCat(a.type) === actCat(activity.type) && a.distance > 0;
      }).sort((a,b) => {
        const ad = a.start_date?.toDate ? a.start_date.toDate() : new Date(a.start_date);
        const bd = b.start_date?.toDate ? b.start_date.toDate() : new Date(b.start_date);
        return bd - ad;
      }).slice(0, 5);

      // Pace comparison (runs only)
      if (isRunActivity && recentSimilar.length > 0 && distMi > 0.5) {
        const avgRecentPace = recentSimilar.reduce((s,a) => s + (a.moving_time / (a.distance/1609.34)), 0) / recentSimilar.length;
        const diff = paceSecPerMi - avgRecentPace;
        if (Math.abs(diff) > 5) {
          const faster = diff < 0;
          const absDiff = Math.abs(Math.round(diff));
          feedback.push({
            type: faster ? "positive" : "neutral",
            text: faster
              ? `${absDiff}s/mi faster than your 30-day avg for similar runs`
              : `${absDiff}s/mi slower than recent avg — could be recovery-paced or conditions`,
          });
        }
      }

      // Cardiac Efficiency Factor: velocity per heart beat, proportional
      // to 1 / (pace × HR). Higher = more efficient.
      //
      // The previous metric (pace ÷ HR) was directionally ambiguous —
      // it grew with both "slower pace" AND "lower HR", so a recovery-
      // paced easy run with a correspondingly low HR (e.g. 10:00/mi at
      // 130 bpm vs 9:10/mi at 140 bpm recent avg) would trip the
      // warning "HR running higher for pace — fatigue, heat,
      // dehydration?" even though HR was lower than recent. EF measures
      // pace-at-HR honestly: same pace at lower HR moves it up, same
      // pace at higher HR moves it down.
      if (isRunActivity && activity.average_heartrate && distMi > 0.5) {
        const ef = 1 / (paceSecPerMi * activity.average_heartrate);
        const recentWithHR = recentSimilar.filter(a => a.average_heartrate && a.distance > 800);
        if (recentWithHR.length >= 2) {
          const avgEf = recentWithHR.reduce((s, a) => {
            const p = a.moving_time / (a.distance / 1609.34);
            return s + 1 / (p * a.average_heartrate);
          }, 0) / recentWithHR.length;
          const efDiff = ((ef - avgEf) / avgEf) * 100;
          if (Math.abs(efDiff) > 3) {
            feedback.push({
              type: efDiff > 0 ? "positive" : "warning",
              text: efDiff > 0
                ? `Cardiac efficiency improved ${efDiff.toFixed(0)}% — better pace per heartbeat than recent avg`
                : `Cardiac efficiency dropped ${Math.abs(efDiff).toFixed(0)}% — HR elevated for pace, watch fatigue / heat / dehydration`,
            });
          }
        }
      }

      // HR zone analysis — use configured zones if available, fall back to %max
      if (activity.average_heartrate) {
        const hr = activity.average_heartrate;
        const { zones: configuredZones, maxHR } = getHRZones(hrZoneConfig);
        const pctMax = (hr / maxHR * 100).toFixed(0);
        let zoneNum = 1, zoneName = "Easy";
        if (configuredZones && configuredZones.length > 0) {
          // Use user's configured zone boundaries (sorted low→high)
          const sorted = [...configuredZones].sort((a, b) => a.minBPM - b.minBPM);
          for (let i = sorted.length - 1; i >= 0; i--) {
            if (hr >= sorted[i].minBPM) {
              zoneNum = sorted[i].zone || (i + 1);
              zoneName = sorted[i].name || sorted[i].label || `Z${zoneNum}`;
              break;
            }
          }
        } else {
          // Fallback: simple %max zones
          zoneNum = hr < maxHR*0.6 ? 1 : hr < maxHR*0.7 ? 2 : hr < maxHR*0.8 ? 3 : hr < maxHR*0.9 ? 4 : 5;
          zoneName = ["", "Recovery", "Aerobic", "Tempo", "Threshold", "VO2max"][zoneNum];
        }
        feedback.push({
          type: "info",
          text: `Avg HR ${Math.round(hr)} bpm (${pctMax}% max) — Zone ${zoneNum} ${zoneName}`,
        });
      }

      // Health context
      if (healthData) {
        if (healthData.hrv != null && healthData.hrv < 40) {
          feedback.push({
            type: "context",
            text: `HRV was ${healthData.hrv}ms — lower readiness may explain harder effort`,
          });
        }
        if (healthData.sleepScore != null && healthData.sleepScore < 65) {
          feedback.push({
            type: "context",
            text: `Sleep score ${healthData.sleepScore}/100 — poor recovery context for this session`,
          });
        }
        if (healthData.bodyBattery != null) {
          const bb = typeof healthData.bodyBattery === "object" ? (healthData.bodyBattery?.value ?? healthData.bodyBattery?.charged) : healthData.bodyBattery;
          if (bb != null) feedback.push({
            type: "context",
            text: `Body Battery at ${Math.round(bb)} pre-workout`,
          });
        }
      }

      // Suffer score / relative effort
      if (activity.suffer_score) {
        const sufferLabel = activity.suffer_score < 50 ? "Easy" : activity.suffer_score < 100 ? "Moderate" : activity.suffer_score < 150 ? "Hard" : "All-out";
        feedback.push({
          type: activity.suffer_score > 150 ? "warning" : "info",
          text: `Relative Effort: ${activity.suffer_score} (${sufferLabel})`,
        });
      }

      return feedback;
    }

    function Summary({ activities, userId, profile, hrZoneConfig, onHrZonesUpdated, isOwner }) {
      // ── All data from centralized DataProvider ──
      const { healthEntries30: healthEntries, healthToday, healthYesterday,
              nutritionHistory, refreshNutrition,
              weightGoal, garminStats, activePlan: activePlanSummary,
              gearList,
              redsRisk, athleteProfile, trainingSnapshot, askCoach,
              requestPlanRefresh, zoneHistory, appendZoneSnapshot } = useData();

      const [hrMethod, setHrMethod] = useState("percentage_max");
      const [raceHistDist, setRaceHistDist] = useState("Marathon");
      const [weather, setWeather] = useState(null);
      // Past races — used by the Cycle Comparison card to align the
      // current prep cycle against a previous one (e.g. NYC vs Philly).
      const [pastRaces, setPastRaces] = useState([]);
      const [cycleCmpRaceId, setCycleCmpRaceId] = useState(null);
      const [cycleCmpMetric, setCycleCmpMetric] = useState("mileage");
      // Weather pacing controls — lifted to component scope so the
      // hooks are always called on every render, regardless of
      // Race Course Profile state (weather sliders, goal-time override,
      // GPS overshoot toggle) lives inside the RaceCourseProfileCard
      // component itself now — Summary doesn't need to lift it anymore.
      useEffect(() => {
        if (!userId) return;
        const unsub = db.collection("users").doc(userId).collection("races")
          .onSnapshot(snap => {
            const all = snap.docs.map(d => ({ id: d.id, ...d.data() }));
            const past = all.filter(r => r.date && new Date(r.date) < new Date())
              .sort((a, b) => new Date(b.date) - new Date(a.date));
            setPastRaces(past);
            // Default the comparison race to the most recent past marathon
            // (or fall back to the most recent past race of any distance).
            if (!cycleCmpRaceId && past.length) {
              const marathons = past.filter(r => r.distance === "marathon");
              setCycleCmpRaceId((marathons[0] || past[0]).id);
            }
          });
        return () => unsub && unsub();
      }, [userId]);
      const [userHRConfig, setUserHRConfig] = useState(() => ({
        maxHR: hrZoneConfig?.maxHR || null,
        restingHR: hrZoneConfig?.restingHR || null,
        lthr: hrZoneConfig?.lthr || null,
        age: hrZoneConfig?.age || null,
        peakHistoricalVO2: hrZoneConfig?.peakHistoricalVO2 || null,
      }));
      useEffect(() => {
        if (!hrZoneConfig) return;
        setUserHRConfig(prev => ({
          ...prev,
          maxHR: hrZoneConfig.maxHR || prev.maxHR,
          restingHR: hrZoneConfig.restingHR || prev.restingHR,
          lthr: hrZoneConfig.lthr || prev.lthr,
          age: hrZoneConfig.age || prev.age,
        }));
      }, [hrZoneConfig]);
      // Auto-populate LTHR from Garmin's measured lactateThrHR.
      //
      // Two paths:
      //   1. If LTHR is unset, fill from Garmin (always).
      //   2. If LTHR is implausibly low for the athlete's max HR
      //      (configured < 88% of maxHR) AND Garmin reports a higher
      //      value, auto-update — the configured value is almost
      //      certainly a stale Garmin reading from before the athlete
      //      built fitness, and leaving it stuck low cascades into
      //      mis-classified zones, mis-classified easy runs, and
      //      garbage aerobic %.
      //
      // The non-stale-but-different case still gets the manual-update
      // banner on the LT card so the user keeps final say when their
      // LTHR is in a plausible range.
      useEffect(() => {
        if (!garminStats?.lactateThrHR) return;
        const garminLTHR = parseInt(garminStats.lactateThrHR);
        if (!Number.isFinite(garminLTHR) || garminLTHR < 100 || garminLTHR > 220) return;
        setUserHRConfig(prev => {
          if (!prev.lthr) return { ...prev, lthr: garminLTHR };
          // Plausibility check: real-world LTHR for a fit runner sits
          // ~88-93% of max HR. If configured is below 88% AND Garmin's
          // newer reading is meaningfully higher, force-update.
          const maxHR = prev.maxHR;
          if (maxHR && prev.lthr < maxHR * 0.88 && garminLTHR > prev.lthr + 5) {
            console.warn(`[LTHR] auto-updating from ${prev.lthr} → ${garminLTHR} (configured below 88% of max ${maxHR})`);
            return { ...prev, lthr: garminLTHR };
          }
          return prev;
        });
      }, [garminStats]);
      const [showHRConfig, setShowHRConfig] = useState(false);
      const [refreshing, setRefreshing] = useState({});

      // Refresh helper — reloads specific data sources
      const refreshWidget = async (widget) => {
        if (!userId) return;
        setRefreshing(prev => ({ ...prev, [widget]: true }));
        try {
          if (widget === "weather") {
            const getWeather = functions.httpsCallable("getWeather");
            const result = await getWeather({ location: "Philadelphia,PA" });
            setWeather(result.data);
          } else if (widget === "health") {
            // Health is live via DataProvider onSnapshot — no manual refresh needed
          } else if (widget === "nutrition") {
            refreshNutrition();
          } else if (widget === "strava") {
            const syncFn = functions.httpsCallable("syncStravaActivities");
            await syncFn({ mode: "recent" });
          }
        } catch (e) { console.warn(`[refresh] ${widget} failed:`, e.message); }
        setRefreshing(prev => ({ ...prev, [widget]: false }));
      };

      // Load weather + HR config (singletons now from DataProvider)
      useEffect(() => {
        if (!userId) return;
        db.collection("users").doc(userId).collection("settings").doc("hrZones")
          .get().then(doc => {
            if (doc.exists) {
              const d = doc.data();
              setUserHRConfig(prev => ({
                ...prev,
                age: d.age || prev.age,
                peakHistoricalVO2: d.peakHistoricalVO2 || prev.peakHistoricalVO2,
              }));
              if (d.hrMethod) setHrMethod(d.hrMethod);
            }
          });
        const getWeather = functions.httpsCallable("getWeather");
        getWeather({ location: "Philadelphia,PA" }).then(result => {
          setWeather(result.data);
        }).catch(err => console.warn("[weather] fetch failed:", err.message));
      }, [userId]);

      // redsRisk now from DataProvider

      const saveHRConfig = (newConfig) => {
        setUserHRConfig(newConfig);
        if (userId) {
          // Stamp maxHRManual whenever the user actively sets a Max HR via
          // the input. The athleteProfile auto-persist re-saves observed
          // max_heartrate as if it were user-set — without this flag the
          // typed value gets reverted on the next render whenever an
          // activity has a higher max_heartrate (sensor spikes routinely
          // hit 200+).
          const payload = { ...newConfig };
          if (newConfig.maxHR != null && newConfig.maxHR > 0) payload.maxHRManual = true;
          db.collection("users").doc(userId).collection("settings").doc("hrZones")
            .set(payload, { merge: true });
          if (onHrZonesUpdated) onHrZonesUpdated(prev => ({ ...(prev || {}), ...payload }));
        }
      };

      const runs = useMemo(() => activities.filter(a => isRun(a)), [activities]);

      // Effective VO2max with explicit source attribution. Order:
      //   1. Garmin Firstbeat (per-activity dynamics, freshest first; stats/performance fallback)
      //   2. Riegel-implied from best recent quality effort — used as a
      //      FLOOR on the calc'd value so a recent fast 5K/10K can pull
      //      the displayed VO2max up to match what predictRaceTimes is
      //      using under the hood. Without this, the page can show
      //      VO2max 36.6 sitting next to a 22:09 5K projection (the
      //      effort implies VO2max ~46).
      //   3. calcEffectiveVO2max from quality runs only (easy/recovery excluded).
      // Pass the athlete's maxHR so intent classification matches the
      // server snapshot (which threads hrConfig.maxHR) — otherwise the
      // client fallback numbers can drift from the snapshot's.
      const intentOpts = useMemo(() => ({ maxHR: hrZoneConfig?.maxHR || null }), [hrZoneConfig]);
      const calculatedVO2 = useMemo(() => calcEffectiveVO2max(runs, 90, new Date(), intentOpts), [runs, intentOpts]);
      const bestEffort = useMemo(() => bestRecentQualityEffort(runs, 90, new Date(), intentOpts), [runs, intentOpts]);

      // Fitness / fatigue / form (CTL / ATL / TSB) computed client-side
      // from the loaded activities via the shared trainingLoad lib — the
      // SAME mirrored module the server snapshot runs, so the Status
      // card's numbers and this chart can never disagree. All modalities
      // count (runs full weight, cross-training discounted), per-session
      // Banister TRIMP on %HR-reserve.
      const fitnessFatigueModel = useMemo(() => {
        const TL = window.TrainingLoad;
        if (!TL) return null;
        const opts = { maxHR: hrZoneConfig?.maxHR || null, restHR: hrZoneConfig?.restingHR || null, days: 180 };
        const series = TL.dailyLoadSeries(activities || [], opts);
        if (series.length < 14) return null;
        const ff = TL.fitnessFatigue(series);
        if (!ff) return null;
        return { points: ff.points, current: ff.current, acwr: TL.acwrEwma(series), monotony: TL.monotonyStrain(series) };
      }, [activities, hrZoneConfig]);
      const garminVO2 = useMemo(() => {
        // Freshest source: daily VO2max captured per-day from
        // metrics-service/maxmet (1dp precision, written every sync). Garmin's
        // app surfaces a value every day even on rest days, so this picks up
        // upward movement that per-activity Firstbeat misses during aerobic
        // blocks.
        const dayWithVO2 = (healthEntries || [])
          .filter(h => h?.garminVO2max > 30)
          .sort((a, b) => (b.date || "").localeCompare(a.date || ""))[0];
        if (dayWithVO2) {
          return { value: dayWithVO2.garminVO2max, source: "garmin-daily", date: dayWithVO2.date };
        }
        // Per-activity Garmin Firstbeat VO2max (intervals-only, lags during easy blocks)
        const recentWithVO2 = runs
          .filter(r => r.dynamics?.garminVO2max > 30)
          .sort((a, b) => (b.start_date?.seconds || 0) - (a.start_date?.seconds || 0));
        if (recentWithVO2.length > 0) {
          return { value: recentWithVO2[0].dynamics.garminVO2max, source: "garmin-activity", date: recentWithVO2[0].start_date };
        }
        // Fall back to stats/performance doc (Garmin perf-page sync)
        if (garminStats?.vo2Max) return { value: parseFloat(garminStats.vo2Max), source: "garmin-stats", date: null };
        return null;
      }, [runs, garminStats, healthEntries]);
      // Pick a single grounding value + label for "where this came from".
      // Garmin's Firstbeat reading is the source of truth — it's live-
      // measured every day, written to health/{date}.garminVO2max by the
      // scheduled sync and the bookmarklet maxMetHistory fan-out. Derived
      // estimates (best recent quality effort, calc'd from top quality
      // runs) fill in only when there's no Garmin reading at all.
      const vo2Source = useMemo(() => {
        // Strongest credible signal wins. Sub-maximal data UNDER-estimates
        // VO2max (easy runs, or a stale/low Garmin Firstbeat reading after
        // wrist-HR dropouts — e.g. 33.6 for someone racing a near-19:00
        // 5K), so a demonstrated race/hard effort should beat a soft Garmin
        // number. Garmin is a floor, not a ceiling.
        const cands = [];
        if (garminVO2) cands.push({ value: garminVO2.value, label: "Garmin Firstbeat", source: garminVO2.source });
        if (bestEffort) {
          const distMi = (bestEffort.dist / 1609.34).toFixed(1);
          const dateStr = bestEffort.date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
          const m = Math.floor(bestEffort.time / 60), s = Math.round(bestEffort.time % 60);
          cands.push({
            value: bestEffort.vo2,
            label: `derived from ${m}:${String(s).padStart(2, "0")} ${distMi}mi (${dateStr})`,
            source: "best-effort",
          });
        }
        if (calculatedVO2) cands.push({ value: calculatedVO2, label: "top quality efforts (90d)", source: "calculated" });
        if (!cands.length) return null;
        return cands.reduce((best, c) => (c.value > best.value ? c : best));
      }, [garminVO2, bestEffort, calculatedVO2]);
      const effectiveVO2 = vo2Source?.value || null;

      // VO2max from 30 days ago (for the card's ▲/▼ delta arrow). Prefer
      // the server-built snapshot value; fall back to a local Garmin lookup
      // (with calc fallback) for sessions before the snapshot was rebuilt.
      const vo2max30dAgo = useMemo(() => {
        const snapVal = trainingSnapshot?.racePrep?.vo2max30dAgo;
        if (snapVal != null) return snapVal;
        const cutoffStr = new Date(Date.now() - 30 * 86400000).toISOString().split("T")[0];
        const garminAt = [...(healthEntries || [])]
          .filter(h => h?.garminVO2max > 30 && (h.date || "") <= cutoffStr)
          .sort((a, b) => (b.date || "").localeCompare(a.date || ""))[0];
        if (garminAt) return garminAt.garminVO2max;
        const olderRuns = runs.filter(r => {
          const d = r.start_date?.toDate ? r.start_date.toDate() : new Date(r.start_date);
          return d.toISOString().split("T")[0] <= cutoffStr;
        });
        return calcEffectiveVO2max(olderRuns, 90);
      }, [runs, healthEntries, trainingSnapshot]);

      // Marathon shape
      const marathonShape = useMemo(() => calcMarathonShape(runs), [runs]);

      // Race predictions — current fitness
      // Calibrate the Riegel race-time exponent against the runner's
      // own historical results. Each past race with a recorded
      // actualTime contributes one data point; with ≥2 points we
      // solve for the per-runner k that fits their history. Fed
      // into predictRaceTimes so projections stop using the
      // generic 1.06 default and start fitting THIS runner.
      const riegelCalibration = useMemo(() => {
        const distMap = { "5K": 5000, "10K": 10000, "half": 21097, "marathon": 42195 };
        const parseHMS = (s) => {
          if (!s) return null;
          const parts = String(s).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] * 60 + parts[1];
          return null;
        };
        const results = (pastRaces || [])
          .map(r => ({ distMeters: distMap[r.distance], finishSec: parseHMS(r.actualTime) }))
          .filter(r => r.distMeters && r.finishSec);
        return window.RiegelCalibration.calibrateRiegelExponent(results);
      }, [pastRaces]);
      // Prediction backtest — replay the CURRENT prediction engine as of
      // the day before each completed race, using only the runs that
      // existed then, and score it against the actual finish. The honest
      // scoreboard for "can I trust the projections". Note: the Riegel
      // exponent is today's calibration (which includes these races), a
      // mild hindsight bias acknowledged in the caption.
      const predictionBacktest = useMemo(() => {
        const distMap = { "5K": 5000, "10K": 10000, "10 Mile": 16093, "Half Marathon": 21097, "Half": 21097, "Marathon": 42195, "50K": 50000 };
        const parseHMS = (t) => { const p = String(t || "").split(":").map(Number); if (!p.length || p.some(isNaN)) return null; return p.length === 3 ? p[0] * 3600 + p[1] * 60 + p[2] : p.length === 2 ? p[0] * 60 + p[1] : null; };
        const rows = [];
        (pastRaces || []).forEach(r => {
          const meters = distMap[r.distance];
          const actual = parseHMS(r.actualTime);
          const raceMs = Date.parse(r.date);
          if (!meters || !actual || !Number.isFinite(raceMs)) return;
          const asOf = new Date(raceMs - 86400000);
          const toD = (x) => x.start_date?.toDate ? x.start_date.toDate() : new Date(x.start_date);
          const prior = runs.filter(x => toD(x) < asOf);
          if (prior.length < 10) return;
          const vo2 = calcEffectiveVO2max(prior, 90, asOf, intentOpts);
          if (!vo2) return;
          const prior90 = prior.filter(x => toD(x).getTime() >= asOf.getTime() - 90 * 86400000);
          const preds = predictRaceTimes(vo2, prior90, riegelCalibration?.exponent, intentOpts);
          const pred = preds && preds.find(p => Math.abs(p.meters - meters) / meters < 0.02);
          if (!pred) return;
          rows.push({ name: r.name || r.distance, date: r.date, distance: r.distance, predicted: pred.seconds, actual, errPct: (actual - pred.seconds) / pred.seconds * 100 });
        });
        rows.sort((a, b) => new Date(b.date) - new Date(a.date));
        if (!rows.length) return null;
        const mean = rows.reduce((s, x) => s + x.errPct, 0) / rows.length;
        const mae = rows.reduce((s, x) => s + Math.abs(x.errPct), 0) / rows.length;
        return { rows, meanPct: Math.round(mean * 10) / 10, maePct: Math.round(mae * 10) / 10 };
      }, [pastRaces, runs, riegelCalibration, intentOpts]);

      const racePredictions = useMemo(() => {
        // Prefer the server-built racePrep.currentRaceTimes (Garmin-sourced
        // VO2max + Riegel calibration from pastRaces, same as we compute
        // below). For any distance the snapshot doesn't carry yet (e.g.
        // when new distances are added before the next sync rebuilds the
        // snapshot), fall back to the matching client-computed row so the
        // table always shows the full set.
        const order = ["400m", "1km", "1 Mile", "5K", "10K", "10 Mile", "Half Marathon", "Marathon", "50K", "50 Mile", "100K"];
        const snapRt = trainingSnapshot?.racePrep?.currentRaceTimes;
        const client = predictRaceTimes(effectiveVO2, runs, riegelCalibration?.exponent, intentOpts) || [];
        const clientByName = Object.fromEntries(client.map(p => [p.name, p]));
        // Monte Carlo finish-time bands — snapshot first (tier 2), client
        // recompute through the same lib as fallback. Optional: null when
        // the VO2 quality pool is too thin for a meaningful band.
        const snapBands = trainingSnapshot?.racePrep?.raceTimeBands;
        const clientMc = snapBands ? null
          : monteCarloRaceProjections(runs, { riegelExponent: riegelCalibration?.exponent, ...intentOpts });
        const bandFor = (name) => {
          if (snapBands && snapBands[name]) return snapBands[name];
          const r = clientMc && clientMc.races.find(x => x.name === name);
          return r ? { p10: r.p10, p90: r.p90 } : null;
        };
        const out = order.map(name => {
          if (snapRt && snapRt[name] && snapRt[name].seconds != null) {
            return { name, seconds: snapRt[name].seconds, pace: snapRt[name].paceSecPerMi, band: bandFor(name) };
          }
          const c = clientByName[name];
          return c ? { name, seconds: c.seconds, pace: c.pace, band: bandFor(name) } : null;
        }).filter(Boolean);
        return out.length ? out : null;
      }, [effectiveVO2, runs, riegelCalibration, trainingSnapshot]);

      // Lactate threshold estimates
      const { maxHR: summaryMaxHR } = useMemo(() => getHRZones(hrZoneConfig, activities), [hrZoneConfig, activities]);
      const ltEstimates = useMemo(() => {
        // Prefer the server-built racePrep.currentLT — same estimator,
        // recomputed in lockstep with weeklyHistory[*].lt so the panel
        // card and the chart's latest dot can't drift apart. Falls back
        // to client compute when the snapshot doesn't carry it.
        const snapLT = trainingSnapshot?.racePrep?.currentLT;
        if (snapLT && snapLT.lt2) return snapLT;
        return estimateLactateThresholds(effectiveVO2, summaryMaxHR, 49, runs, garminStats, userHRConfig?.lthr);
      }, [effectiveVO2, summaryMaxHR, runs, garminStats, userHRConfig, trainingSnapshot]);
      const ltHistory = useMemo(() => {
        // Prefer the server-built racePrep.weeklyHistory.lt — same LT
        // estimator, same hierarchy, same source labels. The client
        // calcLTHistory remains the fallback for sessions before the
        // snapshot was rebuilt to carry per-week LT.
        const snapHistory = trainingSnapshot?.racePrep?.weeklyHistory;
        if (Array.isArray(snapHistory) && snapHistory.length && snapHistory.some(p => p.lt)) {
          return snapHistory
            .filter(p => p.lt)
            .map(p => ({
              week: p.week,
              label: new Date(p.week).toLocaleDateString("en-US", { month: "short", day: "numeric" }),
              vo2max: p.vo2max,
              source: p.source || "garmin",
              lt1HR: p.lt.lt1HR,
              lt2HR: p.lt.lt2HR,
              lt1Pace: p.lt.lt1Pace,
              lt2Pace: p.lt.lt2Pace,
            }));
        }
        return calcLTHistory(runs, summaryMaxHR, "2026-03-24", userHRConfig?.lthr, healthEntries, athleteProfile?.restingHR);
      }, [runs, summaryMaxHR, userHRConfig, healthEntries, trainingSnapshot]);

      // Race-day goal equivalents: given the planned race distance + goal
      // time, derive equivalent times at every other standard distance via
      // Riegel. Shown beneath the Race Time Projections so the athlete can
      // sanity-check that their goal pace is consistent with workout
      // targets — e.g. a sub-3:00 marathon goal implies sub-19:30 5K, and
      // if recent 5Ks are 22:00, the goal is aspirational.
      const raceGoalEquivalents = useMemo(() => {
        if (!activePlanSummary?.goalTime || !activePlanSummary?.raceDistance) return null;
        const goalSec = (() => {
          const cleaned = String(activePlanSummary.goalTime).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;
        })();
        if (!goalSec) return null;
        const distMap = {
          "400m": 400, "1km": 1000, "1 Mile": 1609,
          "5K": 5000, "10K": 10000, "10 Mile": 16093,
          "Half Marathon": 21097, "Marathon": 42195,
          "50K": 50000, "50 Mile": 80467, "100K": 100000,
        };
        const goalMeters = distMap[activePlanSummary.raceDistance] || 42195;
        const goalLabel = `${activePlanSummary.goalTime} ${activePlanSummary.raceDistance}`;
        const rows = Object.entries(distMap)
          .filter(([name]) => name !== activePlanSummary.raceDistance)
          .map(([name, meters]) => {
            const sec = predictFromRace(goalMeters, goalSec, meters);
            return { name, meters, seconds: sec, pace: sec / (meters / 1609.34) };
          });
        return { goalLabel, rows };
      }, [activePlanSummary]);

      // Race time history — weekly snapshots of predicted race times for
      // every standard distance, going back ~16 weeks. Each weekly point
      // reads the Garmin Firstbeat VO2max on-or-before that Monday (the
      // canonical source), falling back to calc-from-quality-runs only
      // when no Garmin reading exists at that point in time. Keeps the
      // trajectory aligned with what the card shows as "Current".
      const garminVO2byDate = useMemo(() => {
        const map = new Map();
        (healthEntries || []).forEach(h => {
          if (h?.garminVO2max > 30 && h.date) map.set(h.date, h.garminVO2max);
        });
        return map;
      }, [healthEntries]);
      const garminVO2sortedDates = useMemo(() =>
        [...garminVO2byDate.keys()].sort()
      , [garminVO2byDate]);
      const garminVO2AsOf = (isoDate) => {
        let best = null;
        for (const d of garminVO2sortedDates) {
          if (d <= isoDate) best = d; else break;
        }
        return best ? garminVO2byDate.get(best) : null;
      };
      const racePredHistory = useMemo(() => {
        // Prefer the server-built derived/trainingSnapshot.racePrep.weeklyHistory
        // when available — single source of truth, consistent across surfaces,
        // no per-mount client recomputation. Fall back to client compute for
        // sessions before the snapshot was rebuilt with racePrep.
        const snapHistory = trainingSnapshot?.racePrep?.weeklyHistory;
        if (Array.isArray(snapHistory) && snapHistory.length) {
          return snapHistory.map(p => {
            const point = {
              isoDate: p.week,
              label: new Date(p.week).toLocaleDateString("en-US", { month: "short", day: "numeric" }),
              vo2: p.vo2max,
              source: p.source || "garmin",
            };
            if (p.raceTimes) {
              ["400m", "1km", "1 Mile", "5K", "10K", "10 Mile", "Half Marathon", "Marathon", "50K", "50 Mile", "100K"].forEach(name => {
                if (p.raceTimes[name] != null) point[name] = p.raceTimes[name];
              });
            }
            return point;
          });
        }
        if (!runs.length) return [];
        const out = [];
        const now = new Date();
        // Anchor on this week's Monday so points line up with calendar weeks.
        const monday = new Date(now);
        monday.setDate(monday.getDate() - ((monday.getDay() + 6) % 7));
        monday.setHours(0, 0, 0, 0);
        // 52 weeks of history now that healthEntries carries a year of
        // Garmin VO2max readings. Pre-Garmin weeks fall back to calc.
        for (let weeksAgo = 52; weeksAgo >= 0; weeksAgo--) {
          const asOf = new Date(monday.getTime() - weeksAgo * 7 * 86400000);
          const isoDate = asOf.toISOString().split("T")[0];
          const isCurrentWeek = weeksAgo === 0;
          const garminVal = garminVO2AsOf(isoDate);
          // Current week uses effectiveVO2 (demonstrated fitness) so the
          // latest dot matches the headline projection; back-weeks stay
          // point-in-time Garmin. Mirrors the server racePrep build.
          const vo2 = (isCurrentWeek && effectiveVO2)
            ? effectiveVO2
            : (garminVal || calcEffectiveVO2max(runs, 90, asOf));
          if (!vo2) continue;
          const priorRuns = runs.filter(r => {
            const d = r.start_date?.toDate ? r.start_date.toDate() : new Date(r.start_date);
            return d <= asOf;
          });
          const preds = predictRaceTimes(vo2, isCurrentWeek ? runs : priorRuns);
          if (!preds) continue;
          const point = {
            isoDate,
            label: asOf.toLocaleDateString("en-US", { month: "short", day: "numeric" }),
            vo2,
            source: garminVal ? "garmin" : "calc",
          };
          preds.forEach(p => { point[p.name] = p.seconds; });
          out.push(point);
        }
        return out;
      }, [runs, garminVO2sortedDates, garminVO2byDate, trainingSnapshot, effectiveVO2]);

      // Race predictions — projected race day fitness
      const raceDayProjection = useMemo(() => {
        // Prefer the server-built racePrep.raceDayProjection — same model,
        // recomputed on every sync, in lockstep with the rest of racePrep.
        // Fall back to client compute when the snapshot is older than this
        // change.
        const snapProj = trainingSnapshot?.racePrep?.raceDayProjection;
        if (snapProj && snapProj.vo2max != null && Array.isArray(snapProj.predictions)) {
          // Server returns raceDate as ISO; format here for the card.
          const d = snapProj.raceDate ? new Date(snapProj.raceDate + "T00:00:00") : null;
          return {
            ...snapProj,
            raceDate: d ? d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }) : snapProj.raceDate,
          };
        }
        try {
        if (!effectiveVO2 || !activePlanSummary) return null;
        const raceDate = activePlanSummary.raceDate ? new Date(activePlanSummary.raceDate) : null;
        if (!raceDate) return null;
        const weeksToRace = Math.max(0, (raceDate - new Date()) / (7 * 86400000));
        if (weeksToRace < 1) return null; // race is this week, current = projected

        // Current weekly mileage (trailing 2-week average)
        const recentMi = runs.slice(0, 14).reduce((s, r) => s + (r.distance || 0) / 1609.34, 0);
        const currentWeeklyMi = Math.max(recentMi / 2, 5);

        // Peak planned mileage — try several data shapes the plan doc may use
        let peakMi = 0;
        const planWeeks = activePlanSummary.weeks || [];
        if (planWeeks.length > 0) {
          peakMi = planWeeks.reduce((m, w) =>
            Math.max(m, w.targetMiles || w.miles || w.plannedMiles || w.totalMiles || 0), 0);
        }
        if (!peakMi && activePlanSummary.peakMileage) peakMi = activePlanSummary.peakMileage;
        if (!peakMi && Array.isArray(activePlanSummary.phases)) {
          peakMi = activePlanSummary.phases.reduce((m, p) =>
            Math.max(m, (p && (p.peakMiles || p.targetMiles || p.miles)) || 0), 0);
        }
        // Fallback: for a marathon plan, assume peak is ~1.6x current (if unreadable)
        if (!peakMi || peakMi < currentWeeklyMi) peakMi = currentWeeklyMi * 1.6;

        // ─── Research-based VO2max improvement model ───────────────────────────
        // Meta-analyses show trained runners improve VO2max 5-12% over a full
        // marathon training cycle. Gains follow an exponential saturation curve.
        // Return-to-previous-fitness is faster than new highs (muscle memory,
        // existing capillary networks, mitochondrial density patterns).

        const trainingWeeks = Math.max(0, weeksToRace - 2); // exclude final taper
        const mileageRatio = peakMi / currentWeeklyMi;      // e.g. 40/25 = 1.6

        // Historical peak: from HR config if user has set it, else derive from health entries
        const historicalPeak = userHRConfig?.peakHistoricalVO2
          || (healthEntries.length > 0
            ? Math.max(...healthEntries.map(h => h.garminVO2max || 0).filter(v => v > 0), 0) || null
            : null);

        // Is this a "return to fitness" scenario? Gains come faster and ceiling is higher.
        const isReturning = historicalPeak && historicalPeak > effectiveVO2 + 2;
        const returningGap = isReturning ? historicalPeak - effectiveVO2 : 0;

        // Max achievable absolute VO2max for this training cycle:
        // - Returning athletes: can realistically reach 90-97% of historical peak
        // - New fitness: effectiveVO2 × (1 + maxGainPct)
        const maxAchievableVO2 = isReturning
          ? Math.min(historicalPeak * 0.97, effectiveVO2 + returningGap * 0.85)
          : effectiveVO2 * (1 + Math.min(
              0.05 + (mileageRatio - 1) * 0.04 + Math.max(0, (70 - effectiveVO2) / 30) * 0.04,
              0.15
            ));
        const maxGainVO2 = maxAchievableVO2 - effectiveVO2;

        // Diminishing returns curve: gain = maxGain × (1 − e^(−weeks/τ))
        // τ = 8w for returning athletes (faster re-adaptation), 12w for new peaks
        const tau = isReturning ? 8 : 12;
        const trainingGain = Math.max(0, maxGainVO2 * (1 - Math.exp(-trainingWeeks / tau)));

        // Taper supercompensation (2.5%): glycogen loading + fatigue dissipation
        const taperBoost = effectiveVO2 * 0.025;

        // Current upward trajectory contributes a small continuation bonus
        const trendBonus = vo2max30dAgo && effectiveVO2 > vo2max30dAgo
          ? (effectiveVO2 - vo2max30dAgo) * 0.25 // 25% of 30-day trend projects forward
          : 0;

        const projectedVO2 = Math.min(
          effectiveVO2 + trainingGain + taperBoost + trendBonus,
          maxAchievableVO2  // ceiling derived from historical peak or % gain model
        );

        // Apply Daniels ratio to current blended predictions so both use the same baseline
        const pVO2 = parseFloat(projectedVO2.toFixed(1));
        const currentPredictions = predictRaceTimes(effectiveVO2, runs);
        const predictions = currentPredictions ? currentPredictions.map(cp => {
          const danielsCurrent = danielsPredict(effectiveVO2, cp.meters);
          const danielsProjected = danielsPredict(pVO2, cp.meters);
          const ratio = danielsCurrent > 0 ? danielsProjected / danielsCurrent : 1;
          const projectedSecs = Math.round(cp.seconds * ratio);
          return { ...cp, seconds: projectedSecs, pace: projectedSecs / (cp.meters / 1609.34) };
        }) : predictRaceTimes(pVO2);

        const gainPct = Math.round((projectedVO2 / effectiveVO2 - 1) * 100);
        return {
          vo2max: pVO2,
          weeksOut: Math.round(weeksToRace),
          raceDate: raceDate.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }),
          raceName: activePlanSummary.raceName,
          predictions,
          currentWeeklyMi: Math.round(currentWeeklyMi),
          peakMi: Math.round(peakMi),
          gainPct,
          isReturning,
          historicalPeak: historicalPeak ? Math.round(historicalPeak * 10) / 10 : null,
        };
        } catch(e) { console.error("[raceDayProjection]", e); return null; }
      }, [effectiveVO2, vo2max30dAgo, activePlanSummary, runs, userHRConfig, healthEntries, trainingSnapshot]);

      // Training paces — anchor on the active plan's marathon goal-pace
      // when set, so the panel matches what the coach is prescribing.
      // Falls back to current-fitness predicted MP otherwise.
      // (`activePlan` is destructured from useData as `activePlanSummary`
      // higher up in this component.)
      const goalMpSec = useMemo(() => planGoalMarathonPaceSec(activePlanSummary), [activePlanSummary]);
      // Weeks-to-race drives the blended pace ramp: while we're building, the
      // TARGET column tracks current fitness and only converges on goal pace
      // as race week nears — instead of pinning ambitious goal pace from day
      // one (which prescribes threshold/MP paces the athlete can't yet hold).
      const paceWeeksToRace = useMemo(() => {
        const rd = activePlanSummary?.raceDate ? new Date(activePlanSummary.raceDate) : null;
        return rd ? Math.max(0, (rd - new Date()) / (7 * 86400000)) : null;
      }, [activePlanSummary]);
      const trainingPaces = useMemo(() => {
        // Single source of truth: the server-built trainingSnapshot.fitness is
        // canonical for training paces. modelledPaces (the same value the
        // Overview "Training paces" card, the AI coach context, the daily
        // email and plan-refresh all read) already folds in the strongest-
        // signal VO2max and the goal ramp. Rebuild the full zone bands from
        // its MP anchor — calcTrainingPaces(null, [], mp) reconstructs the
        // exact bands the snapshot's midpoints came from — so this panel can
        // never drift from the rest of the app. Live-compute is only a
        // fallback for before the first snapshot exists.
        const snap = trainingSnapshot?.fitness?.modelledPaces;
        if (snap?.mp) {
          const zones = calcTrainingPaces(null, [], snap.mp);
          if (zones) { zones.source = snap.source || zones.source; return zones; }
        }
        return calcTrainingPaces(effectiveVO2, runs, goalMpSec, paceWeeksToRace != null ? { weeksToRace: paceWeeksToRace } : {});
      }, [trainingSnapshot, effectiveVO2, runs, goalMpSec, paceWeeksToRace]);

      // Observed paces over the last 8 weeks — what the athlete is actually
      // running. Used to show "Current" alongside the target column in the
      // Training Paces table so the gap (and progression) is visible at a
      // glance. Approximations for Threshold/Interval/Strides because we
      // don't have streams at this scope; but they update with fitness:
      // as intervals get faster, max_speed creeps up; as tempos get faster,
      // whole-run pace of tagged tempo workouts drops.
      const observedPaces = useMemo(() => {
        if (!Array.isArray(runs) || !runs.length) return null;
        const cutoff = Date.now() - 8 * 7 * 86400000;
        const recent = runs.filter(r => {
          const d = r.start_date?.toDate?.() || new Date(r.start_date);
          return d.getTime() >= cutoff && r.distance > 1500 && r.moving_time > 0;
        });
        if (!recent.length) return null;
        const planQ = {};
        (activePlanSummary?.weeks || []).forEach(w => (w.days || []).forEach(d => {
          if (d.linkedActivityId && d.type) planQ[String(d.linkedActivityId)] = (d.type || "").toLowerCase();
        }));
        const median = xs => {
          if (!xs.length) return null;
          const s = xs.slice().sort((a, b) => a - b);
          return s[Math.floor(s.length / 2)];
        };
        const paceOf = r => r.moving_time / (r.distance / 1609.34);
        // Pace of the fastest sustained best-effort segment Strava
        // auto-detected inside a run (tries the given distances in order).
        // Lets us read the QUALITY portion of a workout instead of the
        // jog-diluted whole-run average. Returns null when the activity
        // carries no matching best-effort.
        const bestEffortPace = (r, names) => {
          if (!Array.isArray(r.best_efforts)) return null;
          for (const nm of names) {
            const e = r.best_efforts.find(x => (x.name || "").toLowerCase() === nm && x.moving_time > 0 && x.distance > 0);
            if (e) return e.moving_time / (e.distance / 1609.34);
          }
          return null;
        };
        const out = {};
        // Easy / Recovery — split the easy-classified runs at the median.
        // Slower half is recovery, faster half is easy. A runner who does
        // very few proper recovery jogs will still get a recovery value,
        // but it'll closely match easy (which is the truth — they're not
        // recovering, they're running easy all the time).
        const easyRuns = recent.filter(r => classifyRunIntent(r) === "easy");
        if (easyRuns.length >= 3) {
          const paces = easyRuns.map(paceOf).sort((a, b) => a - b);
          const mid = Math.ceil(paces.length / 2);
          out.easy = Math.round(median(paces.slice(0, mid)));
          out.recovery = Math.round(median(paces.slice(mid - 1)));
        } else if (easyRuns.length) {
          out.easy = Math.round(median(easyRuns.map(paceOf)));
        }
        // Long — runs ≥10mi. Take the median of the faster half so the
        // value reflects demonstrated long-run capability rather than
        // averaging in recovery long runs (which would pull the metric
        // toward easy-pace territory even when the athlete can clearly
        // hold a quicker long-run pace).
        const longRuns = recent.filter(r => r.distance / 1609.34 >= 10);
        if (longRuns.length) {
          const paces = longRuns.map(paceOf).sort((a, b) => a - b);
          const topN = Math.max(1, Math.ceil(paces.length / 2));
          out.long = Math.round(median(paces.slice(0, topN)));
        }
        // Workout split: plan tag first, then Strava workout_type +
        // max_speed-to-avg ratio.
        const tempoTags = new Set(["tempo", "threshold", "race_pace"]);
        const intervalTags = new Set(["intervals", "interval"]);
        const thresholdRuns = [], intervalRuns = [], strideRuns = [];
        recent.forEach(r => {
          const pt = planQ[String(r.id)];
          const distMi = r.distance / 1609.34;
          const ratio = r.average_speed > 0 ? (r.max_speed || 0) / r.average_speed : 1;
          if (intervalTags.has(pt)) intervalRuns.push(r);
          else if (tempoTags.has(pt) || r.workout_type === 1) thresholdRuns.push(r);
          else if (r.workout_type === 3) {
            if (ratio >= 1.35 && distMi < 8) intervalRuns.push(r);
            else thresholdRuns.push(r);
          } else if (classifyRunIntent(r) === "easy" && ratio >= 1.30 && distMi < 9 && r.max_speed > 0) {
            strideRuns.push(r);
          }
        });
        // Threshold: read from Strava's auto-detected sustained best-effort
        // segments (2-mile is the cleanest threshold proxy; 1-mile / 5K
        // convert into a 2-mile equivalent). We harvest the best-effort
        // segment from EVERY recent run and keep only the ones whose PACE is
        // genuinely threshold-or-faster — we do NOT pre-filter by the run's
        // overall intent. That intent filter was the bug: a real tempo whose
        // HR ramped up (started below the threshold zone) plus warmup /
        // cooldown averages out to an "easy"/"neutral" whole-run intent, so
        // the run was skipped entirely and its sustained 6:40–7:00 segment
        // thrown away — leaving the reading to aerobic runs and showing ~8:15
        // for a session actually run at threshold.
        //
        // The pace gate does all the work. A threshold effort is by
        // definition FASTER than marathon pace, whereas an aerobic run's
        // fastest 2 miles sits at easy/steady pace (~8:00+). So keep only
        // segments faster than mp × 1.04 (just past marathon pace): that
        // drops aerobic bests — which otherwise outnumber the real efforts
        // and drag even the faster-half median toward easy pace — while
        // keeping genuine threshold reps regardless of how the run is tagged.
        // Convert 1-mile (~4% faster than 2mi) and 5K (~2% slower) first,
        // then take the median of the faster half.
        const mp = trainingPaces?.mp ?? null;
        const qualityCutoff = mp ? mp * 1.04 : Infinity;
        const tEffortPaces = [];
        recent.forEach(r => {
          // Without a marathon-pace anchor we can't pace-gate, so fall back
          // to the legacy guard (skip easy-classified runs) to avoid easy
          // best-efforts polluting the sample.
          if (mp == null && classifyRunIntent(r) === "easy") return;
          let p = null;
          const e2 = bestEffortPace(r, ["2 mile"]);
          if (e2) p = e2;
          else {
            const e1 = bestEffortPace(r, ["1 mile"]);
            if (e1) p = e1 * 1.04;        // 1-mile is ~4% faster than 2mi
            else {
              const e5 = bestEffortPace(r, ["5k"]);
              if (e5) p = e5 * 0.98;      // 5K is ~2% slower than 2mi
            }
          }
          if (p != null && p <= qualityCutoff) tEffortPaces.push(p);
        });
        if (tEffortPaces.length) {
          const sorted = tEffortPaces.slice().sort((a, b) => a - b);
          const topN = Math.max(1, Math.ceil(sorted.length / 2));
          out.threshold = Math.round(median(sorted.slice(0, topN)));
        } else if (thresholdRuns.length) {
          // No best-effort segment cleared the gate. Fall back to tagged
          // threshold sessions whose whole-run pace is itself quality-level
          // (a jog-diluted "tempo" averaging 8:30 is not a threshold
          // reading). If none qualify, leave Threshold unset — the panel
          // shows the target with no current pace rather than an easy pace.
          const qual = thresholdRuns.map(paceOf).filter(p => p <= qualityCutoff);
          if (qual.length) out.threshold = Math.round(median(qual));
        }
        // Interval whole-run pace is heavily jog-diluted, so prefer the
        // mid-run peak: median of (max_speed × 0.93) across interval
        // sessions. Strava's max_speed is a 1-second peak, so 7% slowdown
        // approximates sustained rep pace.
        if (intervalRuns.length) {
          const proxies = intervalRuns.filter(r => r.max_speed > 0)
            .map(r => 1609.34 / (r.max_speed * 0.93));
          out.interval = proxies.length
            ? Math.round(median(proxies))
            : Math.round(median(intervalRuns.map(paceOf)));
        }
        // Strides are near-max bursts, so max_speed is close to true rep
        // pace.
        if (strideRuns.length) {
          const stridePaces = strideRuns.map(r => 1609.34 / r.max_speed);
          if (stridePaces.length) out.strides = Math.round(median(stridePaces));
        }
        // Sustained / steady efforts — the moderate continuous runs that
        // aren't easy, aren't a long run, and aren't a rep workout (the
        // "3.5mi block" / "15-20min tempo" case). They previously fell
        // through every bucket and were dropped. Classify each by the pace
        // it actually held — best-effort segment when present, else
        // whole-run pace — against the MP-anchored bands, so they land in
        // Marathon / Steady / Threshold by PACE rather than by length.
        if (mp && classifyPaceZone) {
          const classified = new Set([...intervalRuns, ...strideRuns, ...thresholdRuns]);
          const buckets = { marathon: [], steady: [], threshold: [] };
          recent.forEach(r => {
            if (classified.has(r) || classifyRunIntent(r) === "easy") return;
            const distMi = r.distance / 1609.34;
            if (distMi < 3 || distMi >= 10) return; // <3mi = noise, ≥10 = long run
            const pace = bestEffortPace(r, ["2 mile", "1 mile", "5k"]) ?? paceOf(r);
            const zone = classifyPaceZone(pace, mp);
            if (zone === "Marathon") buckets.marathon.push(pace);
            else if (zone === "Steady") buckets.steady.push(pace);
            else if (zone === "Threshold") buckets.threshold.push(pace);
          });
          if (buckets.marathon.length) out.marathon = Math.round(median(buckets.marathon));
          if (buckets.steady.length) out.steady = Math.round(median(buckets.steady));
          // An untagged continuous tempo only informs Threshold when no
          // tagged threshold session already set it (those are authoritative).
          if (out.threshold == null && buckets.threshold.length) out.threshold = Math.round(median(buckets.threshold));
        }
        // Threshold fallback — when no genuine threshold effort was observed
        // (no qualifying best-effort segment, no tagged tempo, no untagged
        // continuous run in the Threshold zone), don't leave the cell blank.
        // Estimate it from the OTHER observed current paces, each scaled by
        // the physiological ratio between its zone and Threshold (from the
        // mp-anchored target bands). Every observed zone casts one estimate;
        // take the median. Keeps Threshold consistent with — and scaled to —
        // the paces the athlete is actually running.
        if (out.threshold == null && Array.isArray(trainingPaces)) {
          const tMid = {};
          trainingPaces.forEach(z => {
            if (z && z.min != null && z.max != null) tMid[z.zone.toLowerCase()] = (z.min + z.max) / 2;
          });
          if (tMid.threshold) {
            const votes = [];
            ["marathon", "steady", "interval", "long", "easy", "recovery", "strides"].forEach(z => {
              if (out[z] != null && tMid[z]) votes.push(out[z] * (tMid.threshold / tMid[z]));
            });
            if (votes.length) out.threshold = Math.round(median(votes));
          }
        }
        return Object.keys(out).length ? out : null;
      }, [runs, activePlanSummary, trainingPaces]);

      // Append a pace-zone snapshot whenever the live trainingPaces
      // materially differ from the most recent stored snapshot. Tracked
      // here (not DataProvider) because trainingPaces is computed from
      // effectiveVO2 / goalMpSec which live in this scope.
      useEffect(() => {
        if (!appendZoneSnapshot || !trainingPaces || !Array.isArray(zoneHistory)) return;
        const snap = window.ZoneHistory.buildPaceSnapshot(trainingPaces);
        if (!snap) return;
        const latest = window.ZoneHistory.latestOfType(zoneHistory, "pace");
        if (latest && !window.ZoneHistory.paceZonesDiffer(snap.zones, latest.zones) && (latest.mpSec || null) === (snap.mpSec || null)) return;
        appendZoneSnapshot(snap);
        // eslint-disable-next-line react-hooks/exhaustive-deps
      }, [trainingPaces, zoneHistory.length, appendZoneSnapshot]);

      // ── Zone history UI helpers ──────────────────────────────────────
      // Expand/collapse the history chart under each zones card.
      const [hrHistoryOpen, setHrHistoryOpen] = useState(false);
      const [paceHistoryOpen, setPaceHistoryOpen] = useState(false);

      const hrHistory = useMemo(
        () => (zoneHistory || []).filter(h => h.type === "hr"),
        [zoneHistory]
      );
      const paceHistory = useMemo(
        () => (zoneHistory || []).filter(h => h.type === "pace"),
        [zoneHistory]
      );
      const latestHr = hrHistory[hrHistory.length - 1] || null;
      const latestPace = paceHistory[paceHistory.length - 1] || null;
      const hrIsNew = window.ZoneHistory.isRecent(latestHr);
      const paceIsNew = window.ZoneHistory.isRecent(latestPace);
      const fmtEffective = (snap) => {
        const ms = window.ZoneHistory.tsToMs(snap?.effectiveAt);
        if (ms == null) return null;
        return new Date(ms).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
      };

      // Inline NEW badge — held for 7 days from the latest snapshot's
      // effectiveAt. Always shown alongside the effective date so the
      // athlete can see when the zones last shifted.
      const NewBadge = ({ snap, label }) => {
        const date = fmtEffective(snap);
        if (!date) return null;
        const recent = window.ZoneHistory.isRecent(snap);
        return (
          <span style={{ display: "inline-flex", alignItems: "center", gap: 6, fontSize: 10, color: C.textMuted, whiteSpace: "nowrap" }}>
            {recent && (
              <span style={{
                fontSize: 9, fontWeight: 700, padding: "1px 6px", borderRadius: 4,
                background: C.cyan + "20", color: C.cyan, letterSpacing: "0.05em",
              }}>NEW</span>
            )}
            <span>{label || "Effective"} {date}</span>
          </span>
        );
      };

      // Shared zone-history chart: stacked colored bands (one per zone)
      // over time. Used by both the HR and pace cards.
      //
      // For HR the Y axis runs lowest BPM at the bottom (so Z5 sits at
      // the top, matching the table). For pace the Y axis is flipped so
      // faster paces (lower seconds) appear at the top — matches how
      // pace tables read top-down from slow → fast.
      const ZoneHistoryChart = ({ snapshots, type }) => {
        if (!Array.isArray(snapshots) || snapshots.length === 0) {
          return <div style={{ fontSize: 11, color: C.textMuted, padding: "8px 0" }}>No history yet — zones will start tracking after the first change.</div>;
        }
        const isHR = type === "hr";
        const valKeys = isHR ? ["minBPM", "maxBPM"] : ["min", "max"];
        const series = snapshots
          .map(s => ({ t: window.ZoneHistory.tsToMs(s.effectiveAt) || Date.now(), zones: s.zones }))
          .filter(s => s.zones && s.zones.length > 0)
          .sort((a, b) => a.t - b.t);
        if (series.length === 0) return null;
        const numZones = series[0].zones.length;
        const allVals = series.flatMap(s => s.zones.flatMap(z => [z[valKeys[0]] || 0, z[valKeys[1]] || 0])).filter(v => v > 0);
        if (allVals.length === 0) return null;
        const vMin = Math.min(...allVals);
        const vMax = Math.max(...allVals);
        const vRange = Math.max(1, vMax - vMin);
        const tMin = series[0].t;
        const tMax = series[series.length - 1].t;
        const W = 360, H = 160;
        const padL = 38, padR = 8, padT = 8, padB = 22;
        const xScale = (t) => series.length === 1
          ? padL + (W - padL - padR) / 2
          : padL + (W - padL - padR) * (t - tMin) / Math.max(1, tMax - tMin);
        // HR: high v at top. Pace: low v (fast) at top.
        const yScale = isHR
          ? (v) => padT + (H - padT - padB) * (1 - (v - vMin) / vRange)
          : (v) => padT + (H - padT - padB) * ((v - vMin) / vRange);
        const HR_COLORS = [C.textMuted, C.green, C.amber, "#f97316", C.red];
        const PACE_COLORS = [C.textMuted, C.green, C.cyan, "#0ea5e9", C.amber, C.red, C.pink];
        const colors = isHR ? HR_COLORS : PACE_COLORS;
        const bands = [];
        for (let zi = 0; zi < numZones; zi++) {
          const topPts = [], botPts = [];
          series.forEach(s => {
            const z = s.zones[zi];
            if (!z) return;
            const x = xScale(s.t);
            topPts.push([x, yScale(z[valKeys[1]] || 0)]);
            botPts.push([x, yScale(z[valKeys[0]] || 0)]);
          });
          if (topPts.length === 0) continue;
          let d;
          if (topPts.length === 1) {
            const halfW = Math.max(12, (W - padL - padR) / 4);
            const [x1, yTop] = topPts[0];
            const [, yBot] = botPts[0];
            d = `M${x1 - halfW},${yTop} L${x1 + halfW},${yTop} L${x1 + halfW},${yBot} L${x1 - halfW},${yBot} Z`;
          } else {
            const topPath = topPts.map(p => `${p[0]},${p[1]}`).join(" L");
            const botPath = botPts.slice().reverse().map(p => `${p[0]},${p[1]}`).join(" L");
            d = `M${topPath} L${botPath} Z`;
          }
          const z0 = series[0].zones[zi];
          bands.push({ d, color: colors[zi] || C.textMuted, name: z0?.name || z0?.abbr || `Z${zi + 1}` });
        }
        const fmtDate = (t) => new Date(t).toLocaleDateString(undefined, { month: "short", day: "numeric" });
        const tickXs = series.length === 1
          ? [{ x: padL + (W - padL - padR) / 2, label: fmtDate(series[0].t) }]
          : [
              { x: xScale(tMin), label: fmtDate(tMin) },
              { x: xScale(tMin + (tMax - tMin) / 2), label: fmtDate(tMin + (tMax - tMin) / 2) },
              { x: xScale(tMax), label: fmtDate(tMax) },
            ];
        const valLabel = (v) => {
          if (isHR) return `${Math.round(v)}`;
          const m = Math.floor(v / 60), s = Math.round(v % 60);
          return `${m}:${String(s).padStart(2, "0")}`;
        };
        const valTicks = [vMin, (vMin + vMax) / 2, vMax].map(v => ({ y: yScale(v), label: valLabel(v) }));
        return (
          <div style={{ marginTop: 10 }}>
            <svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" style={{ width: "100%", height: H, display: "block" }}>
              {valTicks.map((tk, i) => (
                <g key={`vt-${i}`}>
                  <line x1={padL} y1={tk.y} x2={W - padR} y2={tk.y} stroke={C.border} strokeWidth="0.5" strokeDasharray="2,3" />
                  <text x={padL - 4} y={tk.y + 3} fontSize="9" fill={C.textMuted} textAnchor="end" fontFamily="'IBM Plex Mono',monospace">{tk.label}</text>
                </g>
              ))}
              {bands.map((b, i) => (
                <path key={`bn-${i}`} d={b.d} fill={b.color} fillOpacity="0.5" stroke={b.color} strokeWidth="1" strokeOpacity="0.8" />
              ))}
              {tickXs.map((tk, i) => (
                <text key={`tx-${i}`} x={tk.x} y={H - 6} fontSize="9" fill={C.textMuted} textAnchor="middle" fontFamily="'IBM Plex Mono',monospace">{tk.label}</text>
              ))}
            </svg>
            <div style={{ display: "flex", gap: 10, fontSize: 10, color: C.textMuted, flexWrap: "wrap", marginTop: 4 }}>
              {bands.map((b, i) => (
                <span key={`lg-${i}`} style={{ display: "flex", alignItems: "center", gap: 4 }}>
                  <span style={{ width: 10, height: 8, background: b.color, opacity: 0.6, border: `1px solid ${b.color}`, display: "inline-block", borderRadius: 2 }} />
                  <span>{b.name}</span>
                </span>
              ))}
            </div>
            <div style={{ marginTop: 6, fontSize: 10, color: C.textMuted }}>
              {series.length} snapshot{series.length === 1 ? "" : "s"} since {fmtDate(tMin)}.
            </div>
          </div>
        );
      };

      // Workload ratio
      const acRatio = useMemo(() => calcACRatio(activities, athleteProfile), [activities, athleteProfile]);

      // Weekly metrics (this week + last week)
      const thisWeekMetrics = useMemo(() => calcWeeklyMetrics(activities, 0, athleteProfile), [activities, athleteProfile]);
      const lastWeekMetrics = useMemo(() => calcWeeklyMetrics(activities, 1, athleteProfile), [activities, athleteProfile]);

      // Today's activities
      const todayActivities = useMemo(() => {
        const td = new Date();
        td.setHours(0, 0, 0, 0);
        return activities.filter(a => {
          const d = a.start_date?.toDate ? a.start_date.toDate() : new Date(a.start_date);
          return d >= td;
        });
      }, [activities]);

      // Yesterday's activities
      const yesterdayActivities = useMemo(() => {
        const yd = new Date();
        yd.setDate(yd.getDate() - 1);
        yd.setHours(0, 0, 0, 0);
        const ydEnd = new Date(yd);
        ydEnd.setDate(yd.getDate() + 1);
        return activities.filter(a => {
          const d = a.start_date?.toDate ? a.start_date.toDate() : new Date(a.start_date);
          return d >= yd && d < ydEnd;
        });
      }, [activities]);

      // This week miles vs last week
      const weeklyMileage = useMemo(() => {
        const now = new Date();
        const thisWeekStart = new Date(now);
        thisWeekStart.setDate(now.getDate() - ((now.getDay() + 6) % 7));
        thisWeekStart.setHours(0, 0, 0, 0);
        const lastWeekStart = new Date(thisWeekStart);
        lastWeekStart.setDate(thisWeekStart.getDate() - 7);

        let thisWeekMi = 0, lastWeekMi = 0, thisWeekXT = 0;
        activities.forEach(a => {
          const d = a.start_date?.toDate ? a.start_date.toDate() : new Date(a.start_date);
          if (d >= thisWeekStart) {
            if (isRun(a)) thisWeekMi += a.distance / 1609.34;
            else thisWeekXT += (a.moving_time || 0) / 3600;
          } else if (d >= lastWeekStart && d < thisWeekStart) {
            if (isRun(a)) lastWeekMi += a.distance / 1609.34;
          }
        });
        return { thisWeekMi, lastWeekMi, thisWeekXT };
      }, [activities]);

      // VO2max trend — Garmin Firstbeat readings from health/{date}.garminVO2max.
      // Single source of truth (matches the card, the LT chart, and the
      // Race Time History chart). Sourced from the daily scheduled sync
      // and the maxMetHistory backfill, so one dot per day Garmin has
      // a reading.
      const vo2Trend = useMemo(() => {
        return (healthEntries || [])
          .filter(h => h?.garminVO2max > 30 && h.date)
          .sort((a, b) => (a.date || "").localeCompare(b.date || ""))
          .map(h => ({
            date: new Date(h.date).toLocaleDateString("en-US", { month: "short", day: "numeric" }),
            vo2max: h.garminVO2max,
          }));
      }, [healthEntries]);

      // Monthly volume current vs last year
      const monthlyVolume = useMemo(() => {
        const now = new Date();
        const thisMonthStart = new Date(now.getFullYear(), now.getMonth(), 1);
        const lastYearMonthStart = new Date(now.getFullYear() - 1, now.getMonth(), 1);
        const lastYearMonthEnd = new Date(now.getFullYear() - 1, now.getMonth() + 1, 1);
        let thisMi = 0, lastYearMi = 0;
        activities.forEach(a => {
          if (!isRun(a)) return;
          const d = a.start_date?.toDate ? a.start_date.toDate() : new Date(a.start_date);
          if (d >= thisMonthStart) thisMi += a.distance / 1609.34;
          else if (d >= lastYearMonthStart && d < lastYearMonthEnd) lastYearMi += a.distance / 1609.34;
        });
        return { thisMi, lastYearMi };
      }, [activities]);

      const vo2Trend5 = vo2Trend.length > 0 ? (effectiveVO2 && vo2max30dAgo ? effectiveVO2 - vo2max30dAgo : 0) : 0;
      const vo2Arrow = vo2Trend5 > 0.3 ? " ▲" : vo2Trend5 < -0.3 ? " ▼" : " ─";
      const vo2Color = vo2Trend5 > 0.3 ? C.green : vo2Trend5 < -0.3 ? C.red : C.textMuted;

      const acColor = acRatio.ratio > 1.5 ? C.red : acRatio.ratio >= 0.8 && acRatio.ratio <= 1.3 ? C.green : C.amber;
      const acLabel = acRatio.ratio > 1.5 ? "Danger" : acRatio.ratio >= 0.8 && acRatio.ratio <= 1.3 ? "Optimal" : acRatio.ratio < 0.8 ? "Detraining" : "Caution";

      const shapeColor = marathonShape > 70 ? C.green : marathonShape >= 40 ? C.amber : C.red;

      // ── Calorie Balance (today / yesterday) ──
      const calorieBalance = useMemo(() => {
        const todayStr = new Date().toISOString().split("T")[0];
        const yd = new Date(); yd.setDate(yd.getDate() - 1);
        const yesterdayStr = yd.toISOString().split("T")[0];
        const todayNut = nutritionHistory.find(n => n.date === todayStr);
        const yesterdayNut = nutritionHistory.find(n => n.date === yesterdayStr);
        const useYesterday = !!(yesterdayNut && (yesterdayNut.calories > 0));
        const useToday = !useYesterday && !!(todayNut && (todayNut.calories > 0));
        const nut = useYesterday ? yesterdayNut : useToday ? todayNut : null;
        const label = useYesterday ? "Yesterday" : useToday ? "Today" : "Yesterday";
        const dateLabel = useYesterday ? yesterdayStr : todayStr;
        const consumed = nut ? (nut.calories || 0) : null;
        const protein = nut ? (nut.protein || 0) : 0;
        const carbs = nut ? (nut.carbs || 0) : 0;
        const fat = nut ? (nut.fat || 0) : 0;

        const currentWt = redsRisk?.currentWeight;
        // Mifflin-St Jeor BMR (male, 178cm/5'10", 43yo)
        const bmr = currentWt ? Math.round(10 * (currentWt * 0.453592) + 6.25 * 178 - 5 * 43 + 5) : 1800;

        // Prefer Garmin's measured daily total kilocalories (BMR + all activity) when available.
        // This is the most accurate TDEE — Garmin's Firstbeat algorithm accounts for actual HR.
        const healthDay = healthEntries.find(h => h.date === dateLabel);
        let tdee, exerciseComponent, tdeeSource;
        if (healthDay?.totalKilocalories > 0) {
          tdee = healthDay.totalKilocalories;
          exerciseComponent = healthDay.activeKilocalories || (tdee - bmr);
          tdeeSource = "garmin";
        } else {
          // Fallback: BMR + sum of Strava/estimated activity calories for the day
          // Strava `calories` = total metabolic cost (active + resting during exercise)
          // So net above-resting = calories × ~0.85 to avoid double-counting resting BMR
          const dayActivities = (activities || []).filter(a => {
            const d = a.start_date?.toDate ? a.start_date.toDate() : new Date(a.start_date);
            return d.toISOString().split("T")[0] === dateLabel;
          });
          const stravaExCals = dayActivities.reduce((s, a) => s + (a.calories || 0), 0);
          // If Strava has calories, use BMR + active-only portion (strip resting during exercise)
          // active-only ≈ Strava_total × (1 - exercise_fraction_of_day)
          const exerciseSecs = dayActivities.reduce((s, a) => s + (a.moving_time || 0), 0);
          const exerciseFrac = Math.min(0.5, exerciseSecs / 86400);
          const activeAboveResting = stravaExCals > 0
            ? Math.round(stravaExCals * (1 - exerciseFrac))
            : 0;
          exerciseComponent = activeAboveResting;
          tdee = bmr + activeAboveResting;
          tdeeSource = stravaExCals > 0 ? "strava" : "estimate";
          // Final fallback: weekly-average multiplier
          if (tdee <= bmr) {
            tdee = redsRisk?.estimatedTDEE || Math.round(bmr * 1.5);
            exerciseComponent = tdee - bmr;
            tdeeSource = "estimate";
          }
        }

        const net = consumed !== null ? consumed - tdee : null;
        return { consumed, tdee, bmr, exerciseComponent, net, protein, carbs, fat, label, dateLabel, tdeeSource };
      }, [nutritionHistory, redsRisk, healthEntries, activities]);

      const today = new Date().toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" });

      return (
        <div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
          {/* Header */}
          <div>
            <div style={{ fontFamily: "'Space Grotesk', sans-serif", fontSize: 26, fontWeight: 800, color: C.text }}>
              Performance Summary
            </div>
            <div style={{ color: C.textMuted, fontSize: 13, marginTop: 2 }}>{today}</div>
          </div>

          {/* Weather & Health Smart Banner */}
          {(() => {
            const advice = weatherPaceAdvice(weather, healthToday, hrZoneConfig, healthEntries);
            if (!advice) return null;
            const w = advice.weather;
            const sevColors = { red: C.red, amber: C.amber, info: C.cyan };
            return (
              <Card style={{ borderColor: C.cyan + "30", background: `linear-gradient(135deg, ${C.bg}ee, ${C.bg2}ee)` }}>
                <div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 10 }}>
                  <div style={{ display:"flex", alignItems:"center", gap:8 }}>
                    <div style={{ fontSize: 13, fontWeight: 600, color: C.textDim }}>Today's Running Conditions</div>
                    {isOwner && <button onClick={() => refreshWidget("weather")} disabled={refreshing.weather} style={{ background:"none", border:"none", cursor:"pointer", padding:2, color:C.textMuted, fontSize:12, opacity: refreshing.weather ? 0.4 : 0.7, transition:"transform 0.3s", transform: refreshing.weather ? "rotate(360deg)" : "none" }} title="Refresh weather">↻</button>}
                  </div>
                  <div style={{ display: "flex", alignItems: "center", gap: 12, fontSize: 12 }}>
                    {w.tempF != null && <span style={{ fontWeight: 700, color: C.text, fontFamily: "'IBM Plex Mono',monospace" }}>{w.tempF}F</span>}
                    {w.feelsLikeF != null && w.feelsLikeF !== w.tempF && <span style={{ color: C.textMuted }}>feels {w.feelsLikeF}F</span>}
                    {w.humidity != null && <span style={{ color: C.textMuted }}>{w.humidity}%</span>}
                    {w.windMph > 0 && <span style={{ color: C.textMuted }}>{w.windMph}mph</span>}
                    {w.desc && <span style={{ color: C.textMuted, fontStyle: "italic" }}>{w.desc}</span>}
                  </div>
                </div>
                {advice.advice && (
                  <div style={{ fontSize: 13, fontWeight: 600, color: C.text, marginBottom: 10, padding: "8px 12px", background: C.bg3, borderRadius: 8, borderLeft: `3px solid ${weather.heatRisk === "extreme" || weather.heatRisk === "high" ? C.red : weather.heatRisk === "cold" ? C.cyan : C.green}` }}>
                    {advice.advice}
                  </div>
                )}
                {(advice.lines.length > 0 || advice.alerts.length > 0) && (
                  <div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
                    {advice.lines.map((l, i) => (
                      <div key={"l"+i} style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 11, color: sevColors[l.severity] || C.textMuted }}>
                        <span style={{ fontWeight: 700, fontSize: 9, padding: "1px 5px", borderRadius: 4, background: (sevColors[l.severity]||C.cyan) + "20" }}>{l.icon}</span>
                        <span>{l.text}</span>
                      </div>
                    ))}
                    {advice.alerts.map((a, i) => (
                      <div key={"a"+i} style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 11, color: sevColors[a.severity] || C.textMuted }}>
                        <span style={{ width: 6, height: 6, borderRadius: "50%", background: sevColors[a.severity] || C.cyan, flexShrink: 0 }}></span>
                        <span>{a.text}</span>
                      </div>
                    ))}
                  </div>
                )}
                {weather.morning && weather.evening && (
                  <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12, marginTop: 10, paddingTop: 10, borderTop: `1px solid ${C.border}` }}>
                    <div style={{ fontSize: 11 }}>
                      <span style={{ fontWeight: 600, color: C.amber }}>Morning: </span>
                      <span style={{ color: C.textMuted }}>{weather.morning.tempF}F, {weather.morning.humidity}% humidity, {weather.morning.precipChance}% rain</span>
                    </div>
                    <div style={{ fontSize: 11 }}>
                      <span style={{ fontWeight: 600, color: C.purple }}>Evening: </span>
                      <span style={{ color: C.textMuted }}>{weather.evening.tempF}F, {weather.evening.humidity}% humidity, {weather.evening.precipChance}% rain</span>
                    </div>
                  </div>
                )}
              </Card>
            );
          })()}

          {/* ── Calorie Balance (Today) ── */}
          <Card>
            <div style={{ display:"flex", justifyContent:"space-between", alignItems:"center", marginBottom:12 }}>
              <div style={{ fontSize:13, fontWeight:600, color:C.textDim }}>{calorieBalance.label}'s Calorie Balance <span style={{ fontSize:10, fontWeight:400, color:C.textMuted }}>({calorieBalance.dateLabel})</span></div>
              {isOwner && <button onClick={() => refreshWidget("nutrition")} disabled={refreshing.nutrition} style={{ background:"none", border:"none", cursor:"pointer", padding:2, color:C.textMuted, fontSize:12, opacity: refreshing.nutrition ? 0.4 : 0.7 }} title="Refresh nutrition data">↻</button>}
            </div>
            <div style={{ display:"grid", gridTemplateColumns:"1fr 1fr 1fr", gap:8, textAlign:"center" }}>
              <div style={{ padding:"10px 4px", background:C.bg2, borderRadius:8 }}>
                <div style={{ fontSize:18, fontWeight:700, color: calorieBalance.consumed !== null ? C.green : C.textMuted }}>
                  {calorieBalance.consumed !== null ? calorieBalance.consumed.toLocaleString() : "—"}
                </div>
                <div style={{ fontSize:9, color:C.textMuted, marginTop:2 }}>Consumed</div>
              </div>
              <div style={{ padding:"10px 4px", background:C.bg2, borderRadius:8 }}>
                <div style={{ fontSize:18, fontWeight:700, color:C.amber }}>{calorieBalance.tdee.toLocaleString()}</div>
                <div style={{ fontSize:9, color:C.textMuted, marginTop:2, lineHeight:1.3 }}>TDEE<br/><span style={{fontSize:8}}>BMR {calorieBalance.bmr} + Activity {calorieBalance.exerciseComponent}</span></div>
              </div>
              <div style={{ padding:"10px 4px", background:C.bg2, borderRadius:8 }}>
                <div style={{ fontSize:18, fontWeight:700, color: calorieBalance.net !== null ? (calorieBalance.net >= 0 ? C.green : C.red) : C.textMuted }}>
                  {calorieBalance.net !== null ? (calorieBalance.net >= 0 ? "+" : "") + calorieBalance.net.toLocaleString() : "—"}
                </div>
                <div style={{ fontSize:9, color:C.textMuted, marginTop:2 }}>Net Balance</div>
              </div>
            </div>
            {calorieBalance.consumed !== null && (
              <div style={{ display:"flex", gap:16, marginTop:10, justifyContent:"center", fontSize:10, color:C.textMuted }}>
                <span>P: <span style={{ color:C.cyan, fontWeight:600 }}>{calorieBalance.protein}g</span></span>
                <span>C: <span style={{ color:C.amber, fontWeight:600 }}>{calorieBalance.carbs}g</span></span>
                <span>F: <span style={{ color:C.pink, fontWeight:600 }}>{calorieBalance.fat}g</span></span>
              </div>
            )}
            {calorieBalance.consumed === null && (
              <div style={{ fontSize:10, color:C.textMuted, textAlign:"center", marginTop:8 }}>No nutrition data logged — sync MFP from the Nutrition tab</div>
            )}
          </Card>

          {/* ── Weekly Briefing ───────────────────────────────────────
              Auto-generates once per ISO week (cached server-side), a
              2-3 paragraph coach-style recap of last week + outlook
              for the week ahead. Frontend assembles structured data;
              cloud function turns it into prose. */}
          {(() => {
            const [briefing, setBriefing] = useState(null);
            const [briefLoading, setBriefLoading] = useState(false);
            const [briefError, setBriefError] = useState(null);

            // Compute current ISO week key client-side so we can lazy-load
            // a previously-cached briefing instantly without a function call.
            const weekKey = (() => {
              const d = new Date();
              const dt = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
              const dayNum = dt.getUTCDay() || 7;
              dt.setUTCDate(dt.getUTCDate() + 4 - dayNum);
              const yearStart = new Date(Date.UTC(dt.getUTCFullYear(), 0, 1));
              const weekNum = Math.ceil(((dt - yearStart) / 86400000 + 1) / 7);
              return `${dt.getUTCFullYear()}-W${String(weekNum).padStart(2, "0")}`;
            })();

            // Load any cached briefing for this week from Firestore on mount.
            useEffect(() => {
              if (!userId) return;
              db.collection("users").doc(userId).collection("briefings").doc(weekKey).get()
                .then(doc => { if (doc.exists) setBriefing(doc.data()); })
                .catch(e => console.warn("[briefing] cache read:", e.message));
            }, [userId, weekKey]);

            // Build the structured input the cloud function expects.
            const buildBriefingData = () => {
              if (!activePlanSummary?.weeks?.length) return null;
              const weeks = activePlanSummary.weeks;
              const w1Start = window.PlanMath?.planWeek1Start?.(activePlanSummary);
              if (!w1Start) return null;
              const now = new Date();
              const msSinceW1 = now - w1Start;
              const currentIdx = Math.floor(msSinceW1 / (7 * 86400000));
              if (currentIdx <= 0) return null;
              const lastWeekIdx = currentIdx - 1;
              const thisWeekIdx = currentIdx;
              const lastWeek = weeks[lastWeekIdx];
              const thisWeek = weeks[thisWeekIdx];
              if (!lastWeek) return null;

              const RUN_TYPES = window.PlanMath?.RUN_TYPES || ["easy","recovery","tempo","intervals","long","race_pace"];
              const QUAL = new Set(["tempo","intervals","race_pace","threshold"]);

              const plannedMi = window.PlanMath.plannedMiForWeek(lastWeek);
              const actualMi = window.PlanMath.actualMiForWeek(activePlanSummary, lastWeekIdx, activities) || 0;
              const compliancePct = plannedMi > 0 ? Math.round((actualMi / plannedMi) * 100) : null;

              // Activities credited to last week (uses same range as actualMiForWeek).
              const range = window.PlanMath.weekDateRange(activePlanSummary, lastWeekIdx);
              const lastWeekActs = (activities || []).filter(a => {
                if (a.type !== "Run" && a.type !== "VirtualRun") return false;
                const t = a.start_date_local || a.start_date;
                const ms = typeof t === "string" ? Date.parse(t) : (t?.toDate ? t.toDate().getTime() : (t?.seconds ? t.seconds * 1000 : 0));
                return ms >= range.start.getTime() && ms < range.end.getTime();
              });
              const totalSec = lastWeekActs.reduce((s, a) => s + (a.moving_time || 0), 0);
              const totalDistM = lastWeekActs.reduce((s, a) => s + (a.distance || 0), 0);
              const avgPaceSec = totalDistM > 0 ? totalSec / (totalDistM / 1609.34) : null;
              const fmtPace = avgPaceSec ? `${Math.floor(avgPaceSec/60)}:${String(Math.round(avgPaceSec%60)).padStart(2,"0")}` : null;

              // Quality executed in last week (from completed plan days).
              const qualitySessions = (lastWeek.days || [])
                .filter(d => d.completed && QUAL.has((d.type || "").toLowerCase()))
                .map(d => ({ type: d.type, grade: d.completedGrade || null }));
              const qualityScheduled = (lastWeek.days || [])
                .filter(d => QUAL.has((d.type || "").toLowerCase()))
                .map(d => d.type).join(", ") || null;

              // Long run = the single longest run of the week. We
              // deliberately do NOT use the planned long-day's actualMi
              // because that's an aggregated sum across all activities
              // matched to that day-of-week — cross-source duplicates
              // (Strava + Runalyze writing the same run as separate docs
              // with different ids) make it inflate, e.g. a 14mi long run
              // reported as 28mi. The single longest activity is robust
              // to that aggregation. The grade still comes from the long
              // day if it was marked complete.
              const longDay = (lastWeek.days || []).find(d => d.type === "long");
              const longestAct = lastWeekActs.reduce(
                (max, a) => (a.distance || 0) > (max?.distance || 0) ? a : max,
                null,
              );
              const longRunMi = longestAct && longestAct.distance > 0
                ? Math.round(longestAct.distance / 1609.34 * 10) / 10
                : null;
              const longRun = longRunMi != null ? {
                distanceMi: longRunMi,
                classification: longDay?.completedGrade ? `grade ${longDay.completedGrade}` : null,
              } : null;

              // Aerobic %: easy mileage / total mileage.
              const easyMi = lastWeekActs.filter(a => {
                const hr = a.average_heartrate;
                return !hr || hr <= (athleteProfile?.zones?.[0]?.maxBPM || 145);
              }).reduce((s, a) => s + (a.distance || 0), 0) / 1609.34;
              const aerobicPct = totalDistM > 0 ? Math.round((easyMi / (totalDistM / 1609.34)) * 100) : null;

              // Health averages.
              const lwHealth = (healthEntries || []).filter(h => {
                const t = Date.parse(h.date);
                return t >= range.start.getTime() && t < range.end.getTime();
              });
              const avgOf = (key, get) => {
                const xs = lwHealth.map(get).filter(v => Number.isFinite(v));
                return xs.length ? Math.round(xs.reduce((s, v) => s + v, 0) / xs.length) : null;
              };
              const avgHRV = avgOf("hrv", h => Number(h?.hrv?.lastNight ?? h?.hrv?.lastNightAvg ?? (typeof h?.hrv === "number" ? h.hrv : null)));
              const avgRHR = avgOf("rhr", h => Number(h?.restingHR));
              const avgSleep = avgOf("sleep", h => Number(h?.sleepScore ?? h?.sleep?.overall));

              // Nutrition.
              const lwNut = (nutritionHistory || []).filter(n => {
                const t = Date.parse(n.date);
                return t >= range.start.getTime() && t < range.end.getTime();
              });
              const avgIntake = lwNut.length ? Math.round(lwNut.reduce((s, n) => s + (n.calories || 0), 0) / lwNut.length) : null;
              const avgProtein = lwNut.length ? Math.round(lwNut.reduce((s, n) => s + (n.protein || 0), 0) / lwNut.length) : null;

              // Weight delta (avg this week vs prior).
              const lwWt = lwHealth.filter(h => h.weight).map(h => h.weight);
              const priorRange = window.PlanMath.weekDateRange(activePlanSummary, Math.max(0, lastWeekIdx - 1));
              const pwHealth = priorRange ? (healthEntries || []).filter(h => {
                const t = Date.parse(h.date);
                return t >= priorRange.start.getTime() && t < priorRange.end.getTime();
              }) : [];
              const pwWt = pwHealth.filter(h => h.weight).map(h => h.weight);
              const lwAvg = lwWt.length ? lwWt.reduce((s, v) => s + v, 0) / lwWt.length : null;
              const pwAvg = pwWt.length ? pwWt.reduce((s, v) => s + v, 0) / pwWt.length : null;
              const weightDelta = (lwAvg != null && pwAvg != null) ? lwAvg - pwAvg : null;

              // Notable flags from REDS / overtraining if present.
              const notableFlags = [];
              if (redsRisk?.riskLevel === "high") notableFlags.push("RED-S high risk");
              else if (redsRisk?.riskLevel === "moderate") notableFlags.push("RED-S moderate risk");
              if (compliancePct != null && compliancePct < 80) notableFlags.push(`compliance ${compliancePct}%`);

              // Next week.
              const nwTarget = thisWeek ? window.PlanMath.plannedMiForWeek(thisWeek) : null;
              const nwLong = thisWeek ? (thisWeek.days || []).find(d => d.type === "long") : null;
              const nwQuality = thisWeek ? (thisWeek.days || [])
                .filter(d => QUAL.has((d.type || "").toLowerCase()))
                .map(d => `${d.day} ${d.type} ${d.distanceMi || ""}mi`).join("; ") : null;

              return {
                profile: { name: "Chris", age: 45 },
                race: {
                  name: activePlanSummary.raceName,
                  date: activePlanSummary.raceDate,
                  goalTime: activePlanSummary.goalTime,
                  weeksOut: activePlanSummary.raceDate ? Math.max(0, Math.ceil((Date.parse(activePlanSummary.raceDate) - Date.now()) / (7 * 86400000))) : null,
                  phase: thisWeek?.phase || lastWeek?.phase,
                },
                lastWeek: {
                  weekNum: lastWeekIdx + 1,
                  plannedMi: Math.round(plannedMi),
                  actualMi: Math.round(actualMi * 10) / 10,
                  compliancePct,
                  runs: lastWeekActs.length,
                  avgPace: fmtPace,
                  qualitySessions,
                  qualityScheduled,
                  longRun,
                  aerobicPct,
                  avgHRV, avgRHR, avgSleep,
                  avgIntake, avgProtein,
                  weightDelta,
                  notableFlags,
                },
                nextWeek: {
                  weekNum: thisWeekIdx + 1,
                  targetMi: nwTarget ? Math.round(nwTarget) : null,
                  deltaVsLast: (nwTarget && actualMi) ? Math.round((nwTarget - actualMi) * 10) / 10 : null,
                  longRunMi: nwLong?.distanceMi,
                  longRunNote: nwLong?.description ? nwLong.description.slice(0, 80) : null,
                  qualityScheduled: nwQuality,
                  theme: thisWeek?.theme,
                },
              };
            };

            const generateBriefing = async (force = false) => {
              const briefingData = buildBriefingData();
              if (!briefingData) { setBriefError("Need an active plan with at least one completed week."); return; }
              setBriefLoading(true); setBriefError(null);
              try {
                const fn = functions.httpsCallable("generateWeeklyBriefing", { timeout: 90000 });
                const result = await fn({ briefingData, force });
                setBriefing(result.data);
              } catch (e) {
                setBriefError(e.message || "Generation failed");
              }
              setBriefLoading(false);
            };

            // No active plan with prior week → don't render at all.
            if (!activePlanSummary?.weeks?.length) return null;

            const hasBriefing = briefing && briefing.briefing;
            return (
              <Card>
                <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 4, flexWrap: "wrap", gap: 8 }}>
                  <div style={{ fontSize: 13, fontWeight: 600, color: C.textDim }}>
                    Weekly Briefing
                    <span style={{ fontSize: 10, color: C.textMuted, marginLeft: 8, fontWeight: 400 }}>
                      {weekKey} · last week recap + week ahead
                    </span>
                  </div>
                  {isOwner && (
                    <div style={{ display: "flex", gap: 6 }}>
                      {!hasBriefing && (
                        <button onClick={() => generateBriefing(false)} disabled={briefLoading}
                          style={{ padding: "5px 12px", borderRadius: 6, border: `1px solid ${C.cyan}`, background: C.cyan + "20", color: C.cyan, fontSize: 11, fontWeight: 700, cursor: briefLoading ? "wait" : "pointer" }}>
                          {briefLoading ? "Generating…" : "Generate"}
                        </button>
                      )}
                      {hasBriefing && (
                        <button onClick={() => generateBriefing(true)} disabled={briefLoading} title="Re-generate ignoring cache"
                          style={{ padding: "5px 10px", borderRadius: 6, border: `1px solid ${C.border}`, background: "transparent", color: C.textMuted, fontSize: 11, fontWeight: 600, cursor: briefLoading ? "wait" : "pointer" }}>
                          {briefLoading ? "Refreshing…" : "↻ Refresh"}
                        </button>
                      )}
                    </div>
                  )}
                </div>
                {briefError && (
                  <div style={{ fontSize: 11, color: C.red, marginTop: 8 }}>Error: {briefError}</div>
                )}
                {hasBriefing ? (
                  <div style={{ marginTop: 10, fontSize: 13, color: C.text, lineHeight: 1.7, whiteSpace: "pre-wrap" }}>
                    {briefing.briefing}
                  </div>
                ) : !briefError && !briefLoading && (
                  <div style={{ marginTop: 8, fontSize: 11, color: C.textMuted, lineHeight: 1.5, fontStyle: "italic" }}>
                    Click Generate for a coach-style recap of last week + outlook for the week ahead. Cached server-side once per week — refresh anytime to regenerate.
                  </div>
                )}
              </Card>
            );
          })()}

          {/* ── Bite-sized status widgets row ─────────────────────────
              Three compact tiles for at-a-glance status: RED-S risk
              score, glycogen replenishment %, and plan compliance %.
              Each links/hints toward the dedicated detail tab. */}
          <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))", gap: 12 }}>
            {/* RED-S widget */}
            {redsRisk && (
              <Card style={{ borderColor: redsRisk.riskColor + "40", padding: 14 }}>
                <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
                  <div style={{ position: "relative", width: 54, height: 54, flexShrink: 0 }}>
                    <svg viewBox="0 0 36 36" style={{ transform: "rotate(-90deg)", width: 54, height: 54 }}>
                      <circle cx="18" cy="18" r="15" fill="none" stroke={C.bg3} strokeWidth="3" />
                      <circle cx="18" cy="18" r="15" fill="none" stroke={redsRisk.riskColor} strokeWidth="3"
                        strokeDasharray={`${redsRisk.score * 0.942} 94.2`} strokeLinecap="round" />
                    </svg>
                    <div style={{ position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center",
                      fontSize: 14, fontWeight: 800, color: redsRisk.riskColor }}>
                      {redsRisk.score}
                    </div>
                  </div>
                  <div style={{ flex: 1, minWidth: 0 }}>
                    <div style={{ fontSize: 10, color: C.textMuted, textTransform: "uppercase", letterSpacing: "0.05em", fontWeight: 600 }}>RED-S Risk</div>
                    <div style={{ fontSize: 14, fontWeight: 700, color: redsRisk.riskColor }}>{redsRisk.riskLevel} risk</div>
                    <div style={{ fontSize: 9, color: C.textMuted, marginTop: 2 }}>
                      EA {redsRisk.ea ?? "?"} · {(redsRisk.flags || []).length} flag{(redsRisk.flags || []).length === 1 ? "" : "s"}
                    </div>
                  </div>
                </div>
              </Card>
            )}

            {/* Glycogen widget — anchored tank ("stores full as of
                <date>", Nutrition page) when set, else the logged-food
                replenishment rate */}
            {redsRisk?.fuel && (() => {
              const fuel = redsRisk.fuel;
              const tank = fuel.glycogenTankPct != null;
              const pct = tank ? fuel.glycogenTankPct : fuel.glycogenPct;
              const col = pct >= 80 ? C.green : pct >= 50 ? C.amber : C.red;
              return (
              <Card style={{ borderColor: col + "40", padding: 14 }}>
                <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
                  <div style={{ position: "relative", width: 54, height: 54, flexShrink: 0 }}>
                    <svg viewBox="0 0 36 36" style={{ transform: "rotate(-90deg)", width: 54, height: 54 }}>
                      <circle cx="18" cy="18" r="15" fill="none" stroke={C.bg3} strokeWidth="3" />
                      <circle cx="18" cy="18" r="15" fill="none"
                        stroke={col}
                        strokeWidth="3" strokeDasharray={`${pct * 0.942} 94.2`} strokeLinecap="round" />
                    </svg>
                    <div style={{ position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center",
                      fontSize: 13, fontWeight: 800, color: col }}>
                      {pct}%
                    </div>
                  </div>
                  <div style={{ flex: 1, minWidth: 0 }}>
                    <div style={{ fontSize: 10, color: C.textMuted, textTransform: "uppercase", letterSpacing: "0.05em", fontWeight: 600 }}>Glycogen</div>
                    {tank ? (
                      <React.Fragment>
                        <div style={{ fontSize: 14, fontWeight: 700, color: col }}>
                          ~{fuel.glycogenTankG}g stores
                        </div>
                        <div style={{ fontSize: 9, color: C.textMuted, marginTop: 2 }}>
                          full at start of {fuel.glycogenFullAsOf}
                          {fuel.glycogenHeldDays > 0 ? ` · ${fuel.glycogenHeldDays}d unlogged held` : ""}
                        </div>
                      </React.Fragment>
                    ) : (
                      <React.Fragment>
                        <div style={{ fontSize: 14, fontWeight: 700, color: fuel.glycogenBalance >= 0 ? C.green : C.red }}>
                          {fuel.glycogenBalance >= 0 ? "+" : ""}{fuel.glycogenBalance}g/d
                        </div>
                        <div style={{ fontSize: 9, color: C.textMuted, marginTop: 2 }}>
                          replenishment vs use
                        </div>
                      </React.Fragment>
                    )}
                  </div>
                </div>
              </Card>
              );
            })()}

            {/* Plan Compliance widget */}
            {activePlanSummary?.weeks?.length > 0 && (() => {
              if (!window.PlanMath?.planWeek1Start?.(activePlanSummary)) return null;
              const data = activePlanSummary.weeks.map((week, wi) => {
                const range = window.PlanMath.weekDateRange(activePlanSummary, wi);
                const isFuture = range.start > new Date();
                const isCurrent = range.start <= new Date() && range.end > new Date();
                const prescribed = Math.round(window.PlanMath.plannedMiForWeek(week));
                const actual = isFuture ? null
                  : Math.round(window.PlanMath.actualMiForWeek(activePlanSummary, wi, activities) * 10) / 10;
                return { prescribed, actual, isCurrent, isFuture };
              });
              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;
              if (compliancePct == null) return null;
              const color = compliancePct >= 90 ? C.green : compliancePct >= 70 ? C.amber : C.red;
              const label = compliancePct >= 90 ? "On plan" : compliancePct >= 70 ? "Mostly" : "Behind";
              return (
                <Card style={{ borderColor: color + "40", padding: 14 }}>
                  <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
                    <div style={{ position: "relative", width: 54, height: 54, flexShrink: 0 }}>
                      <svg viewBox="0 0 36 36" style={{ transform: "rotate(-90deg)", width: 54, height: 54 }}>
                        <circle cx="18" cy="18" r="15" fill="none" stroke={C.bg3} strokeWidth="3" />
                        <circle cx="18" cy="18" r="15" fill="none" stroke={color} strokeWidth="3"
                          strokeDasharray={`${Math.min(100, compliancePct) * 0.942} 94.2`} strokeLinecap="round" />
                      </svg>
                      <div style={{ position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center",
                        fontSize: 12, fontWeight: 800, color }}>
                        {compliancePct}%
                      </div>
                    </div>
                    <div style={{ flex: 1, minWidth: 0 }}>
                      <div style={{ fontSize: 10, color: C.textMuted, textTransform: "uppercase", letterSpacing: "0.05em", fontWeight: 600 }}>Plan Compliance</div>
                      <div style={{ fontSize: 14, fontWeight: 700, color }}>{label}</div>
                      <div style={{ fontSize: 9, color: C.textMuted, marginTop: 2 }}>
                        {completed.length} completed week{completed.length === 1 ? "" : "s"}
                      </div>
                    </div>
                  </div>
                </Card>
              );
            })()}
          </div>

          {/* ── RED-S Risk & Weight Management ── */}
          {redsRisk && (
            <Card style={{ borderColor: redsRisk.riskColor + "40" }}>
              <div style={{ display:"flex", justifyContent:"space-between", alignItems:"flex-start", marginBottom:12 }}>
                <div>
                  <div style={{ display:"flex", alignItems:"center", gap:8 }}>
                    <div style={{ fontSize:13, fontWeight:600, color:C.textDim }}>RED-S Risk Monitor</div>
                    {isOwner && <button onClick={() => { refreshWidget("health"); refreshWidget("nutrition"); }} disabled={refreshing.health || refreshing.nutrition} style={{ background:"none", border:"none", cursor:"pointer", padding:2, color:C.textMuted, fontSize:12, opacity: (refreshing.health || refreshing.nutrition) ? 0.4 : 0.7 }} title="Refresh health & nutrition data">↻</button>}
                  </div>
                  <div style={{ fontSize:10, color:C.textMuted, marginTop:2 }}>Relative Energy Deficiency in Sport</div>
                </div>
                <div style={{ display:"flex", alignItems:"center", gap:10 }}>
                  <div style={{ textAlign:"right" }}>
                    <div style={{ fontSize:24, fontWeight:800, color:redsRisk.riskColor }}>{redsRisk.score}</div>
                    <div style={{ fontSize:9, color:C.textMuted, textTransform:"uppercase", letterSpacing:"0.08em" }}>{redsRisk.riskLevel} risk</div>
                  </div>
                  {/* Score arc visualization */}
                  <div style={{ position:"relative", width:48, height:48 }}>
                    <svg viewBox="0 0 36 36" style={{ transform:"rotate(-90deg)", width:48, height:48 }}>
                      <circle cx="18" cy="18" r="15" fill="none" stroke={C.bg3} strokeWidth="3" />
                      <circle cx="18" cy="18" r="15" fill="none" stroke={redsRisk.riskColor} strokeWidth="3"
                        strokeDasharray={`${redsRisk.score * 0.942} 94.2`} strokeLinecap="round" />
                    </svg>
                  </div>
                </div>
              </div>

              {/* Key metrics row */}
              <div style={{ display:"grid", gridTemplateColumns:"repeat(auto-fit,minmax(90px,1fr))", gap:8, marginBottom:12 }}>
                {redsRisk.ea !== null && (
                  <div style={{ textAlign:"center", padding:"8px 4px", background:C.bg2, borderRadius:6 }}>
                    <div style={{ fontSize:16, fontWeight:700, color: redsRisk.ea < 30 ? C.red : redsRisk.ea < 45 ? C.amber : C.green }}>{redsRisk.ea}</div>
                    <div style={{ fontSize:8, color:C.textMuted, lineHeight:1.3 }}>EA kcal/kg<br/>FFM/day</div>
                  </div>
                )}
                {redsRisk.avgIntake !== null && (
                  <div style={{ textAlign:"center", padding:"8px 4px", background:C.bg2, borderRadius:6 }}>
                    <div style={{ fontSize:16, fontWeight:700, color:C.pink }}>{redsRisk.avgIntake}</div>
                    <div style={{ fontSize:8, color:C.textMuted }}>Avg intake</div>
                  </div>
                )}
                <div style={{ textAlign:"center", padding:"8px 4px", background:C.bg2, borderRadius:6 }}>
                  <div style={{ fontSize:16, fontWeight:700, color:C.amber }}>{redsRisk.estimatedTDEE}</div>
                  <div style={{ fontSize:8, color:C.textMuted, lineHeight:1.3 }}>TDEE<br/><span style={{fontSize:7}}>BMR {calorieBalance.bmr} + activity</span></div>
                </div>
                {redsRisk.currentWeight && (
                  <div style={{ textAlign:"center", padding:"8px 4px", background:C.bg2, borderRadius:6 }}>
                    <div style={{ fontSize:16, fontWeight:700, color:C.text }}>{redsRisk.currentWeight}</div>
                    <div style={{ fontSize:8, color:C.textMuted }}>Weight (lb)</div>
                  </div>
                )}
                {redsRisk.weightVelocity !== null && (
                  <div style={{ textAlign:"center", padding:"8px 4px", background:C.bg2, borderRadius:6 }}>
                    <div style={{ fontSize:16, fontWeight:700, color: redsRisk.weightTrend.includes("rapid") ? C.red : redsRisk.weightVelocity < 0 ? C.green : C.amber }}>
                      {redsRisk.weightVelocity > 0 ? "+" : ""}{redsRisk.weightVelocity.toFixed(1)}
                    </div>
                    <div style={{ fontSize:8, color:C.textMuted }}>lb/week</div>
                  </div>
                )}
                {redsRisk.proteinPerKg && (
                  <div style={{ textAlign:"center", padding:"8px 4px", background:C.bg2, borderRadius:6 }}>
                    <div style={{ fontSize:16, fontWeight:700, color: parseFloat(redsRisk.proteinPerKg) < 1.2 ? C.red : C.green }}>{redsRisk.proteinPerKg}</div>
                    <div style={{ fontSize:8, color:C.textMuted }}>g/kg protein</div>
                  </div>
                )}
                {redsRisk.avgDailyDeficit !== null && (
                  <div style={{ textAlign:"center", padding:"8px 4px", background:C.bg2, borderRadius:6 }}>
                    <div style={{ fontSize:16, fontWeight:700, color: redsRisk.avgDailyDeficit > 500 ? C.red : redsRisk.avgDailyDeficit > 200 ? C.amber : C.green }}>
                      {redsRisk.avgDailyDeficit > 0 ? "-" : "+"}{Math.abs(redsRisk.avgDailyDeficit)}
                    </div>
                    <div style={{ fontSize:8, color:C.textMuted }}>Avg deficit</div>
                  </div>
                )}
              </div>

              {/* Energy balance breakdown */}
              {redsRisk.avgIntake !== null && (
                <div style={{ padding:"8px 12px", background:C.bg2, borderRadius:6, marginBottom:12, fontSize:11, color:C.textMuted, lineHeight:1.8 }}>
                  <span style={{ fontWeight:600, color:C.textDim }}>Energy model:</span>{" "}
                  Avg intake <span style={{ color:C.pink, fontWeight:600 }}>{redsRisk.avgIntake}</span> kcal
                  {" − "}TDEE <span style={{ color:C.amber, fontWeight:600 }}>{redsRisk.estimatedTDEE}</span>
                  <span style={{ fontSize:9 }}> (BMR {calorieBalance.bmr} + activity)</span>
                  {" = "}<span style={{ color: redsRisk.avgDailyDeficit > 200 ? C.red : C.green, fontWeight:700 }}>
                    {redsRisk.avgDailyDeficit > 0 ? "-" : "+"}{Math.abs(redsRisk.avgDailyDeficit || 0)} kcal/day
                  </span>
                  {redsRisk.projectedWeeklyChange !== null && (
                    <span>{" → "}<span style={{ fontWeight:600, color:C.textDim }}>
                      {redsRisk.projectedWeeklyChange > 0 ? "-" : "+"}{Math.abs(redsRisk.projectedWeeklyChange).toFixed(1)} lb/wk projected
                    </span></span>
                  )}
                </div>
              )}

              {/* Athlete-declared reset — sits with the numbers it corrects */}
              <NutritionResetControl isOwner={isOwner} />

              {/* Flags */}
              {redsRisk.flags.length > 0 && (
                <div style={{ display:"flex", flexDirection:"column", gap:6, marginBottom:12 }}>
                  {redsRisk.flags.map((f, i) => {
                    const flagColor = f.severity === "critical" ? C.red : f.severity === "high" ? "#f97316" : f.severity === "moderate" ? C.amber : f.severity === "info" ? C.cyan : C.textMuted;
                    return (
                      <div key={i} style={{ display:"flex", gap:8, alignItems:"flex-start", padding:"6px 10px",
                        background:flagColor+"08", borderRadius:6, borderLeft:`3px solid ${flagColor}` }}>
                        <span style={{ fontSize:10, fontWeight:700, color:flagColor, flexShrink:0, marginTop:1 }}>
                          {f.severity === "critical" ? "!!" : f.severity === "high" ? "!" : f.severity === "moderate" ? "~" : "i"}
                        </span>
                        <span style={{ fontSize:11, color:C.textDim, lineHeight:1.5 }}>{f.text}</span>
                      </div>
                    );
                  })}
                </div>
              )}

              {/* Weight projection */}
              {redsRisk.projectedWeight4Weeks && redsRisk.avgDailyDeficit !== null && (
                <div style={{ padding:10, background:C.bg2, borderRadius:8, marginBottom:12, display:"grid", gridTemplateColumns:"1fr 1fr 1fr", gap:8, textAlign:"center" }}>
                  <div>
                    <div style={{ fontSize:10, color:C.textMuted, marginBottom:2 }}>Est. TDEE</div>
                    <div style={{ fontSize:14, fontWeight:700, color:C.amber }}>{redsRisk.estimatedTDEE}</div>
                  </div>
                  <div>
                    <div style={{ fontSize:10, color:C.textMuted, marginBottom:2 }}>Avg deficit</div>
                    <div style={{ fontSize:14, fontWeight:700, color: redsRisk.avgDailyDeficit > 0 ? C.green : C.red }}>
                      {redsRisk.avgDailyDeficit > 0 ? "-" : "+"}{Math.abs(redsRisk.avgDailyDeficit)}
                    </div>
                  </div>
                  <div>
                    <div style={{ fontSize:10, color:C.textMuted, marginBottom:2 }}>Proj. 4 wks</div>
                    <div style={{ fontSize:14, fontWeight:700, color:C.text }}>{redsRisk.projectedWeight4Weeks} lb</div>
                  </div>
                </div>
              )}

              {/* Suggestions */}
              {redsRisk.suggestions.length > 0 && (
                <div style={{ borderTop:`1px solid ${C.border}`, paddingTop:10 }}>
                  <div style={{ fontSize:10, fontWeight:600, color:C.textMuted, textTransform:"uppercase", letterSpacing:"0.08em", marginBottom:6 }}>Recommendations</div>
                  {redsRisk.suggestions.map((s, i) => (
                    <div key={i} style={{ fontSize:11, color:C.textDim, lineHeight:1.6, marginBottom:4, paddingLeft:10, borderLeft:`2px solid ${C.cyan}30` }}>{s}</div>
                  ))}
                </div>
              )}

              {/* Data quality indicator */}
              <div style={{ display:"flex", gap:12, marginTop:8, fontSize:9, color:C.textMuted }}>
                <span>Nutrition: {redsRisk.dataQuality.nutritionDays}/14 days</span>
                <span>Weight: {redsRisk.dataQuality.weightEntries} entries</span>
                <span>Health: {redsRisk.dataQuality.healthEntries} entries</span>
                {redsRisk.weeklyTrainingHours && <span>Training: {redsRisk.weeklyTrainingHours} hrs/wk</span>}
              </div>
            </Card>
          )}


          {/* Today's Activity + Workout Feedback */}
          {todayActivities.length > 0 && (
            <Card style={{ borderColor: C.cyan + "40" }}>
              <div style={{ fontSize: 13, fontWeight: 600, color: C.cyan, marginBottom: 12 }}>Today</div>
              <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
                {todayActivities.map((act, i) => {
                  const disp = actDisplay(act.type);
                  const pace = isRun(act) && act.moving_time && act.distance
                    ? fmt.pace(act.moving_time / (act.distance / 1609.34)) : null;
                  const vo2 = isRun(act) && act.distance > 1500 && act.moving_time
                    ? estimateVO2max(act.distance, act.moving_time) : null;
                  const feedback = generateWorkoutFeedback(act, activities, healthToday, hrZoneConfig);
                  return (
                    <div key={i}>
                      <div style={{ display: "flex", alignItems: "center", gap: 12, flexWrap: "wrap" }}>
                        <span style={{ fontSize: 18 }}>{disp.emoji}</span>
                        <span style={{ fontSize: 13, fontWeight: 600, color: C.text }}>{act.name || disp.label}</span>
                        {act.distance > 0 && <Badge label={fmt.dist(act.distance)} color={disp.color} />}
                        <Badge label={fmt.duration(act.moving_time)} color={C.textDim} />
                        {pace && <Badge label={pace} color={C.amber} />}
                        {act.average_heartrate && <Badge label={fmt.hr(act.average_heartrate)} color={C.pink} />}
                        {vo2 && <Badge label={`VO2: ${vo2}`} color={C.cyan} />}
                      </div>
                      {feedback.length > 0 && (
                        <div style={{ marginTop: 8, marginLeft: 30, display: "flex", flexDirection: "column", gap: 4 }}>
                          {feedback.map((fb, fi) => {
                            const fbColor = fb.type === "positive" ? C.green : fb.type === "warning" ? C.amber : fb.type === "context" ? C.purple : C.textMuted;
                            return (
                              <div key={fi} style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 11 }}>
                                <span style={{ width: 5, height: 5, borderRadius: "50%", background: fbColor, flexShrink: 0 }}></span>
                                <span style={{ color: fbColor }}>{fb.text}</span>
                              </div>
                            );
                          })}
                        </div>
                      )}
                    </div>
                  );
                })}
              </div>
            </Card>
          )}

          {/* Yesterday's Recap + Feedback */}
          <Card style={{ borderColor: C.purple + "40" }}>
            <div style={{ fontSize: 13, fontWeight: 600, color: C.textDim, marginBottom: 12 }}>Yesterday's Recap</div>
            {yesterdayActivities.length === 0 ? (
              <div style={{ fontSize: 14, color: C.textMuted }}>Rest day — no activities recorded</div>
            ) : (
              <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
                {yesterdayActivities.map((act, i) => {
                  const disp = actDisplay(act.type);
                  const pace = isRun(act) && act.moving_time && act.distance
                    ? fmt.pace(act.moving_time / (act.distance / 1609.34)) : null;
                  const feedback = generateWorkoutFeedback(act, activities, healthYesterday, hrZoneConfig);
                  return (
                    <div key={i}>
                      <div style={{ display: "flex", alignItems: "center", gap: 12, flexWrap: "wrap" }}>
                        <span style={{ fontSize: 18 }}>{disp.emoji}</span>
                        <span style={{ fontSize: 13, fontWeight: 600, color: C.text }}>{act.name || disp.label}</span>
                        {act.distance > 0 && <Badge label={fmt.dist(act.distance)} color={disp.color} />}
                        <Badge label={fmt.duration(act.moving_time)} color={C.textDim} />
                        {pace && <Badge label={pace} color={C.amber} />}
                        {act.average_heartrate && <Badge label={fmt.hr(act.average_heartrate)} color={C.pink} />}
                      </div>
                      {feedback.length > 0 && (
                        <div style={{ marginTop: 8, marginLeft: 30, display: "flex", flexDirection: "column", gap: 4 }}>
                          {feedback.map((fb, fi) => {
                            const fbColor = fb.type === "positive" ? C.green : fb.type === "warning" ? C.amber : fb.type === "context" ? C.purple : C.textMuted;
                            return (
                              <div key={fi} style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 11 }}>
                                <span style={{ width: 5, height: 5, borderRadius: "50%", background: fbColor, flexShrink: 0 }}></span>
                                <span style={{ color: fbColor }}>{fb.text}</span>
                              </div>
                            );
                          })}
                        </div>
                      )}
                    </div>
                  );
                })}
              </div>
            )}
            {healthYesterday && (
              <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(90px, 1fr))", gap: 10, marginTop: 14, paddingTop: 14, borderTop: `1px solid ${C.border}` }}>
                {healthYesterday.hrv != null && <Stat label="HRV" value={healthYesterday.hrv} unit="ms" color={C.cyan} size={18} />}
                {healthYesterday.restingHR != null && <Stat label="RHR" value={healthYesterday.restingHR} unit="bpm" color={C.pink} size={18} />}
                {healthYesterday.sleepScore != null && <Stat label="Sleep" value={healthYesterday.sleepScore} unit="/100" color={C.purple} size={18} />}
                {healthYesterday.bodyBattery != null && <Stat label="Battery" value={healthYesterday.bodyBattery} unit="%" color={C.green} size={18} />}
              </div>
            )}
          </Card>

          {/* Current Shape Panel */}
          <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(200px, 1fr))", gap: 16 }}>
            <Card>
              <Stat label={garminVO2 ? "VO2max (Garmin)" : "Effective VO2max"} value={effectiveVO2 || "--"} unit="" color={C.cyan} size={32} />
              <div style={{ marginTop: 8, fontSize: 12, color: vo2Color, fontWeight: 600 }}>
                {vo2Arrow} {vo2Trend5 !== 0 ? `${vo2Trend5 > 0 ? "+" : ""}${vo2Trend5.toFixed(1)} vs 30d ago` : "stable"}
              </div>
              {vo2Source && (
                <div style={{ marginTop: 4, fontSize: 10, color: C.textMuted }}>
                  Source: <span style={{ color: C.text }}>{vo2Source.label}</span>
                </div>
              )}
              {garminVO2 && calculatedVO2 && Math.abs(garminVO2.value - calculatedVO2) > 1 && (
                <div style={{ marginTop: 2, fontSize: 10, color: C.textMuted }}>
                  Daniels formula: {calculatedVO2.toFixed(1)}
                </div>
              )}
              {userHRConfig?.peakHistoricalVO2 && (
                <div style={{ marginTop: 2, fontSize: 10, color: C.textMuted }}>
                  Peak (historical): <span style={{ color: C.text }}>{userHRConfig.peakHistoricalVO2}</span>
                </div>
              )}
              {(!effectiveVO2 || (vo2Source && (vo2Source.source === "calculated" || vo2Source.source === "best-effort"))) && (
                <div style={{ marginTop: 6, fontSize: 10, color: C.amber, lineHeight: 1.4 }}>
                  No fresh Garmin VO2max here — this is inferred from your runs only. Run a hard 5K/tempo to recalibrate.
                </div>
              )}
            </Card>
            <Card>
              <Stat label="Marathon Shape" value={marathonShape != null ? marathonShape : "--"} unit="%" color={shapeColor} size={32} />
              <div style={{ marginTop: 8, fontSize: 11, color: C.textMuted }}>
                {marathonShape > 70 ? "Race ready" : marathonShape >= 40 ? "Building fitness" : marathonShape != null ? "Early base phase" : "Need more data"}
              </div>
            </Card>
            <Card>
              <Stat label="Workload Ratio" value={acRatio.ratio || "--"} unit="A:C" color={acColor} size={32} />
              <div style={{ marginTop: 8, display: "flex", gap: 6, alignItems: "center" }}>
                <Badge label={acLabel} color={acColor} />
                <span style={{ fontSize: 11, color: C.textMuted }}>sweet spot 0.8-1.3</span>
              </div>
            </Card>
            <Card>
              <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
                <Stat label="Weekly TRIMP" value={thisWeekMetrics.totalTRIMP} unit="" color={C.amber} size={22} />
                <Stat label="Monotony" value={thisWeekMetrics.monotony} unit="" color={thisWeekMetrics.monotony > 2 ? C.red : C.textDim} size={18} />
                <Stat label="Strain" value={thisWeekMetrics.strain} unit="" color={C.textDim} size={18} />
              </div>
              {thisWeekMetrics.monotony > 2 && (
                <div style={{ marginTop: 8, fontSize: 11, color: C.red }}>High monotony — vary your training</div>
              )}
            </Card>
          </div>

          {/* ── Super Insights ────────────────────────────────────────
              Cross-stream synthesis layer. Each row is a one-line
              verdict generated by running/lib/superInsights.js from a
              combination of streams (health + activities + nutrition +
              REDS risk). Sorted critical → warn → info → good. Click
              any row to expand its supporting detail. */}
          {(() => {
            if (!window.SuperInsights) return null;
            const insights = window.SuperInsights.computeInsights({
              healthEntries, healthToday, healthYesterday, activities,
              redsRisk,
            });
            if (!insights || insights.length === 0) return null;
            const sevColor = (s) => s === "critical" ? C.red : s === "warn" ? C.amber : s === "good" ? C.green : C.cyan;
            const sevIcon = (s) => s === "critical" ? "⚠️" : s === "warn" ? "•" : s === "good" ? "✓" : "ℹ";
            return (
              <Card>
                <div style={{ fontSize: 13, fontWeight: 600, color: C.textDim, marginBottom: 4 }}>
                  Insights
                  <span style={{ fontSize: 10, color: C.textMuted, marginLeft: 8, fontWeight: 400 }}>
                    Cross-stream synthesis · {insights.length} active
                  </span>
                </div>
                <div style={{ fontSize: 10, color: C.textMuted, marginBottom: 12, lineHeight: 1.5 }}>
                  Each row blends signals across health, training load, dynamics, and nutrition. Click for the supporting evidence.
                </div>
                <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
                  {insights.map(ins => {
                    const col = sevColor(ins.severity);
                    return (
                      <details key={ins.id} style={{ background: C.bg2, borderRadius: 8, border: `1px solid ${col}30`, padding: "0", overflow: "hidden" }}>
                        <summary style={{ padding: "10px 12px", cursor: "pointer", listStyle: "none", display: "flex", gap: 10, alignItems: "flex-start" }}>
                          <span style={{ flex: "0 0 auto", color: col, fontWeight: 700, fontSize: 14 }}>{sevIcon(ins.severity)}</span>
                          <span style={{ flex: 1, minWidth: 0 }}>
                            <span style={{ fontSize: 11, fontWeight: 700, color: col, textTransform: "uppercase", letterSpacing: "0.05em" }}>{ins.title}</span>
                            <div style={{ fontSize: 12, color: C.text, marginTop: 2, lineHeight: 1.4 }}>{ins.summary}</div>
                          </span>
                        </summary>
                        <div style={{ padding: "0 12px 12px 36px", fontSize: 11, color: C.textMuted, lineHeight: 1.6 }}>
                          {ins.detail && <div style={{ marginBottom: ins.action ? 6 : 0 }}>{ins.detail}</div>}
                          {ins.action && (
                            <div style={{ padding: "8px 10px", background: col + "10", border: `1px solid ${col}30`, borderRadius: 6, color: C.text, fontStyle: "italic" }}>
                              → {ins.action}
                            </div>
                          )}
                          {ins.sources && ins.sources.length > 0 && (
                            <div style={{ marginTop: 6, fontSize: 9, color: C.textMuted, opacity: 0.7 }}>
                              sources: {ins.sources.join(" · ")}
                            </div>
                          )}
                        </div>
                      </details>
                    );
                  })}
                </div>
              </Card>
            );
          })()}


          {/* Race Readiness + Training Paces — 2 columns */}
          <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(300px, 1fr))", gap: 16 }}>
            {/* Race Time Projections */}
            <Card>
              <div style={{ fontSize: 13, fontWeight: 600, color: C.textDim, marginBottom: 14 }}>Race Time Projections</div>
              {racePredictions ? (
                <div style={{ display: "flex", flexDirection: "column", gap: 0 }}>
                  {/* Header */}
                  <div style={{ display: "grid", gridTemplateColumns: raceDayProjection ? "100px 1fr 1fr 1fr 1fr" : "110px 1fr 1fr", gap: 8, alignItems: "center", paddingBottom: 8, borderBottom: `1px solid ${C.border}` }}>
                    <div style={{ fontSize: 10, color: C.textMuted, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.05em" }}>Distance</div>
                    {raceDayProjection ? (
                      <>
                        <div style={{ fontSize: 10, color: C.textMuted, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.05em" }}>Current</div>
                        <div style={{ fontSize: 10, color: C.textMuted, fontWeight: 700 }}>Pace</div>
                        <div style={{ fontSize: 10, color: C.cyan, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.05em" }}>Race Day</div>
                        <div style={{ fontSize: 10, color: C.cyan, fontWeight: 700 }}>Pace</div>
                      </>
                    ) : (
                      <>
                        <div style={{ fontSize: 10, color: C.textMuted, fontWeight: 700, textTransform: "uppercase" }}>Time</div>
                        <div style={{ fontSize: 10, color: C.textMuted, fontWeight: 700 }}>Pace</div>
                      </>
                    )}
                  </div>
                  {racePredictions.map((rp, i) => {
                    const proj = raceDayProjection?.predictions?.find(p => p.name === rp.name);
                    const improvement = proj ? rp.seconds - proj.seconds : 0;
                    return (
                      <div key={rp.name} style={{
                        display: "grid", gridTemplateColumns: raceDayProjection ? "100px 1fr 1fr 1fr 1fr" : "110px 1fr 1fr", gap: 8, alignItems: "center",
                        padding: "10px 0", borderBottom: i < racePredictions.length - 1 ? `1px solid ${C.border}` : "none",
                      }}>
                        <div style={{ fontSize: 13, fontWeight: 600, color: shapeColor }}>{rp.name}</div>
                        <div>
                          <div style={{ fontSize: 14, fontWeight: 700, color: C.text, fontFamily: "'Space Grotesk', sans-serif" }}>
                            {fmt.duration(rp.seconds)}
                          </div>
                          {rp.band && rp.band.p10 != null && (
                            <div style={{ fontSize: 9, color: C.textMuted, fontFamily: "'IBM Plex Mono',monospace" }} title="80% band — Monte Carlo over the VO2 quality pool">
                              {fmt.duration(rp.band.p10)}–{fmt.duration(rp.band.p90)}
                            </div>
                          )}
                        </div>
                        <div style={{ fontSize: 11, color: C.amber }}>{fmt.pace(rp.pace)}</div>
                        {proj && (
                          <>
                            <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
                              <span style={{ fontSize: 14, fontWeight: 700, color: C.cyan, fontFamily: "'Space Grotesk', sans-serif" }}>{fmt.duration(proj.seconds)}</span>
                              {improvement > 30 && <span style={{ fontSize: 9, color: C.green, fontWeight: 600 }}>-{fmt.duration(Math.round(improvement))}</span>}
                            </div>
                            <div style={{ fontSize: 11, color: C.cyan }}>{fmt.pace(proj.pace)}</div>
                          </>
                        )}
                      </div>
                    );
                  })}
                  {/* VO2max summary row */}
                  <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginTop: 10, padding: "10px 12px", background: C.bg2, borderRadius: 8 }}>
                    <div style={{ fontSize: 11, color: C.textMuted }}>
                      Current VO2max: <span style={{ color: C.text, fontWeight: 700 }}>{effectiveVO2}</span>
                      {vo2max30dAgo && effectiveVO2 !== vo2max30dAgo && (
                        <span style={{ color: effectiveVO2 > vo2max30dAgo ? C.green : C.red, fontSize: 10, marginLeft: 6 }}>
                          {effectiveVO2 > vo2max30dAgo ? "▲" : "▼"} {Math.abs(effectiveVO2 - vo2max30dAgo).toFixed(1)} (30d)
                        </span>
                      )}
                      {vo2Source && (
                        <span style={{ fontSize: 10, color: C.textMuted, marginLeft: 8, fontStyle: "italic" }}>
                          · {vo2Source.label}
                        </span>
                      )}
                      {riegelCalibration?.source !== "default" && (
                        <span style={{ fontSize: 10, color: C.textMuted, marginLeft: 8, fontStyle: "italic" }}
                          title={`Riegel exponent calibrated from ${riegelCalibration.n} past race${riegelCalibration.n === 1 ? "" : "s"} with actualTime — fits your history rather than the generic 1.06.`}>
                          · Riegel k={riegelCalibration.exponent} ({riegelCalibration.n} race{riegelCalibration.n === 1 ? "" : "s"})
                        </span>
                      )}
                    </div>
                    {raceDayProjection && (
                      <div style={{ fontSize: 11, color: C.textMuted }}>
                        Projected: <span style={{ color: C.cyan, fontWeight: 700 }}>{raceDayProjection.vo2max}</span>
                        <span style={{ fontSize: 10, color: C.textMuted, marginLeft: 6 }}>
                          ({raceDayProjection.raceName} — {raceDayProjection.raceDate}, {raceDayProjection.weeksOut}w out)
                        </span>
                      </div>
                    )}
                  </div>

                  {/* Race-day equivalents — if you hit your goal time at the
                      planned race distance, what does that imply at every
                      other distance via Riegel? Helps the athlete sanity-
                      check whether their goal is consistent with workouts. */}
                  {raceGoalEquivalents && (
                    <div style={{ marginTop: 8, padding: "8px 12px", background: C.bg2, borderRadius: 8, fontSize: 11 }}>
                      <div style={{ color: C.textMuted, marginBottom: 4 }}>
                        If you hit <span style={{ color: C.cyan, fontWeight: 700 }}>{raceGoalEquivalents.goalLabel}</span>, equivalents:
                      </div>
                      <div style={{ display: "flex", gap: 14, flexWrap: "wrap", color: C.textDim }}>
                        {raceGoalEquivalents.rows.map(eq => (
                          <span key={eq.name}>
                            <span style={{ color: C.textMuted }}>{eq.name}:</span>{" "}
                            <span style={{ color: C.text, fontWeight: 700, fontFamily: "'Space Grotesk', sans-serif" }}>{fmt.duration(eq.seconds)}</span>
                            {" "}<span style={{ color: C.textMuted, fontSize: 10 }}>({fmt.pace(eq.pace).replace(" /mi", "")})</span>
                          </span>
                        ))}
                      </div>
                    </div>
                  )}
                  {raceDayProjection && (
                    <div style={{ fontSize: 10, color: C.textMuted, marginTop: 6, fontStyle: "italic", lineHeight: 1.5 }}>
                      Projected +{raceDayProjection.gainPct}% VO2max over {raceDayProjection.weeksOut}w — {raceDayProjection.currentWeeklyMi}→{raceDayProjection.peakMi} mi/wk volume ramp.
                      {raceDayProjection.isReturning && raceDayProjection.historicalPeak && (
                        <span style={{ color: C.cyan }}> Return-to-fitness model (peak {raceDayProjection.historicalPeak}) — faster re-adaptation applied.</span>
                      )}
                    </div>
                  )}
                </div>
              ) : (
                <div style={{ fontSize: 13, color: C.textMuted }}>Need more run data for predictions</div>
              )}
            </Card>

            {/* Training Paces */}
            <Card>
              <div style={{ display:"flex", justifyContent:"space-between", alignItems:"center", marginBottom:14, flexWrap:"wrap", gap:6 }}>
                <div style={{ display:"flex", alignItems:"center", gap:10, flexWrap:"wrap" }}>
                  <div style={{ fontSize: 13, fontWeight: 600, color: C.textDim }}>Training Paces</div>
                  <NewBadge snap={latestPace} />
                </div>
                <div style={{ display:"flex", alignItems:"center", gap:8, flexWrap:"wrap" }}>
                  {paceHistory.length > 0 && (
                    <button
                      onClick={() => setPaceHistoryOpen(o => !o)}
                      title={paceHistoryOpen ? "Hide history" : "Show pace history"}
                      style={{
                        padding:"4px 10px", borderRadius:5, border:`1px solid ${C.border}`,
                        background:"transparent", color:C.textDim,
                        fontSize:11, fontWeight:600, cursor:"pointer",
                        fontFamily:"'IBM Plex Mono',monospace", whiteSpace:"nowrap",
                      }}>
                      {paceHistoryOpen ? "▾ History" : "▸ History"}
                    </button>
                  )}
                  {isOwner && trainingPaces && requestPlanRefresh && (
                    <button
                      onClick={() => requestPlanRefresh()}
                      title="Smart-Refresh every non-locked week so each prescribed targetPace matches the paces shown here. Completed / skipped / manually-edited days stay locked."
                      style={{
                        padding:"4px 10px", borderRadius:5, border:`1px solid ${C.amber}40`,
                        background:"transparent", color:C.amber,
                        fontSize:11, fontWeight:600, cursor:"pointer",
                        fontFamily:"'IBM Plex Mono',monospace", whiteSpace:"nowrap",
                      }}>
                      ↺ Refresh plan to these paces
                    </button>
                  )}
                  {isOwner && <button onClick={() => refreshWidget("strava")} disabled={refreshing.strava} style={{ background:"none", border:"none", cursor:"pointer", padding:2, color:C.textMuted, fontSize:12, opacity: refreshing.strava ? 0.4 : 0.7 }} title="Sync latest activities">↻</button>}
                </div>
              </div>
              {paceHistoryOpen && (
                <div style={{ padding: "10px 14px", background: C.bg2, borderRadius: 8, marginBottom: 12, borderLeft: `3px solid ${C.amber}` }}>
                  <div style={{ fontSize: 11, fontWeight: 600, color: C.textDim, marginBottom: 6 }}>Pace zone history (sec/mi, faster = top)</div>
                  <ZoneHistoryChart snapshots={paceHistory} type="pace" />
                </div>
              )}
              {trainingPaces ? (
                <div style={{ display: "flex", flexDirection: "column", gap: 0 }}>
                  <div style={{
                    display: "grid", gridTemplateColumns: "auto 1fr auto auto", gap: 12,
                    alignItems: "center", padding: "0 0 6px 0",
                    fontSize: 9, color: C.textMuted, fontWeight: 700, letterSpacing: 0.5,
                  }}>
                    <span style={{ width: 30 }}></span>
                    <span></span>
                    <span style={{ textAlign: "right" }}>TARGET</span>
                    <span style={{ textAlign: "right", minWidth: 64 }}>CURRENT</span>
                  </div>
                  {trainingPaces.map((tp, i) => {
                    const obsKey = tp.zone.toLowerCase();
                    const obs = observedPaces?.[obsKey] ?? null;
                    // Typical workout SHAPE for each zone — the zone is the
                    // pace band (left), this is how that pace is usually run.
                    // Distance/duration is a separate axis from the zone: a
                    // 3.5mi block or a 15-min sustained effort is classified
                    // by the pace it held, not its length.
                    const repGuide = {
                      REC: "easy continuous · 3–6 mi",
                      E:   "easy continuous · 4–10 mi",
                      L:   "continuous · 13–22 mi",
                      S:   "sustained aerobic · 3–8 mi / MP long-run segments",
                      M:   "race or MP segments · 6–16 mi",
                      T:   "tempo · 15–25 min sustained or 1–2 mi cruise reps",
                      I:   "VO2 reps · 800m–1200m (3–5 min)",
                      R:   "short reps · 100–200m (20–40s)",
                    }[tp.abbr];
                    // Colour the current value vs the target band: within
                    // = green, faster = cyan (ahead of schedule), slower
                    // = amber (room to push). Muted when unknown.
                    let obsColor = C.textMuted;
                    if (obs != null && tp.min != null && tp.max != null) {
                      if (obs >= tp.min && obs <= tp.max) obsColor = C.green;
                      else if (obs < tp.min) obsColor = C.cyan;
                      else obsColor = C.amber;
                    }
                    // Treadmill speed (sec/mi → mph) as a small dim sub-line
                    // under each pace, so treadmill users don't have to
                    // convert. Faster pace (lower sec) = higher mph.
                    const mph = (sec) => (sec > 0 ? (3600 / sec).toFixed(1) : null);
                    const tMaxMph = mph(tp.max), tMinMph = mph(tp.min), obsMph = mph(obs);
                    return (
                      <div key={tp.zone} style={{
                        display: "grid", gridTemplateColumns: "auto 1fr auto auto", gap: 12, alignItems: "center",
                        padding: "8px 0", borderBottom: i < trainingPaces.length - 1 ? `1px solid ${C.border}` : "none",
                      }}>
                        <Badge label={tp.abbr} color={
                          tp.abbr === "REC" ? C.textMuted :
                          tp.abbr === "E" ? C.green :
                          tp.abbr === "S" ? C.purple :
                          tp.abbr === "M" ? C.cyan :
                          tp.abbr === "T" ? C.amber :
                          tp.abbr === "I" ? C.red : C.pink
                        } />
                        <div>
                          <div style={{ fontSize: 12, color: C.textDim }}>{tp.zone}</div>
                          {repGuide && <div style={{ fontSize: 9, color: C.textMuted, marginTop: 1 }}>{repGuide}</div>}
                        </div>
                        <div style={{ textAlign: "right", fontFamily: "'IBM Plex Mono',monospace" }}>
                          <div style={{ fontSize: 13, fontWeight: 600, color: C.text }}>
                            {tp.max ? fmt.pace(tp.max).split(" ")[0] : ""}
                            {tp.max && tp.min ? "–" : ""}
                            {tp.min ? fmt.pace(tp.min).split(" ")[0] : "faster"}
                          </div>
                          {(tMaxMph || tMinMph) && (
                            <div style={{ fontSize: 10, fontWeight: 600, color: C.textMuted, marginTop: 1 }}>
                              {tMaxMph || ""}{tMaxMph && tMinMph ? "–" : ""}{tMinMph || ""} mph
                            </div>
                          )}
                        </div>
                        <div style={{ textAlign: "right", fontFamily: "'IBM Plex Mono',monospace", minWidth: 64 }}>
                          <div style={{ fontSize: 13, fontWeight: 600, color: obsColor }}>
                            {obs != null ? fmt.pace(obs).split(" ")[0] : "—"}
                          </div>
                          {obsMph && <div style={{ fontSize: 10, fontWeight: 600, color: C.textMuted, marginTop: 1 }}>{obsMph} mph</div>}
                        </div>
                      </div>
                    );
                  })}
                  {(() => {
                    if (!trainingPaces.source) return null;
                    const fmtPace = sec => {
                      const m = Math.floor(sec / 60);
                      const s = Math.round(sec % 60);
                      return `${m}:${String(s).padStart(2, "0")}/mi`;
                    };
                    // Current-fitness MP for the "building from current fitness
                    // (X)" reference — prefer the snapshot's effectiveVDOT so it
                    // matches the canonical MP anchor above, falling back to the
                    // live client VO2max before the first snapshot exists.
                    const fitnessVO2 = trainingSnapshot?.fitness?.effectiveVDOT || effectiveVO2;
                    const fitnessPreds = fitnessVO2 ? predictRaceTimes(fitnessVO2, runs || []) : null;
                    const fitnessMp = fitnessPreds?.find(p => p.name === "Marathon")?.pace;
                    if (trainingPaces.source === "goal") {
                      // Show the gap to current-fitness MP so the runner
                      // sees whether the goal is realistic vs. current shape.
                      return (
                        <div style={{ marginTop: 10, paddingTop: 10, borderTop: `1px solid ${C.border}`, fontSize: 10, color: C.textMuted, lineHeight: 1.5 }}>
                          Anchored on goal MP <strong style={{ color: C.text }}>{fmtPace(trainingPaces.mp)}</strong>
                          {fitnessMp ? <> — current fitness predicts {fmtPace(fitnessMp)} MP.</> : "."}
                        </div>
                      );
                    }
                    return (
                      <div style={{ marginTop: 10, paddingTop: 10, borderTop: `1px solid ${C.border}`, fontSize: 10, color: C.textMuted, lineHeight: 1.5 }}>
                        Anchored on current-fitness MP <strong style={{ color: C.text }}>{fmtPace(trainingPaces.mp)}</strong> (no marathon goal configured — set one to anchor on goal pace instead).
                      </div>
                    );
                  })()}
                </div>
              ) : (
                <div style={{ fontSize: 13, color: C.textMuted }}>Need VO2max estimate or marathon goal for paces</div>
              )}
            </Card>
          </div>

          {/* Race Time History — predicted times for each distance over the
              past 16 weeks, with a horizontal goal line at the race-day
              target. Tabbed by distance so each chart has full y-axis
              resolution (a 5K time of 22:00 vs a marathon of 3:30 in the
              same axis would compress 5K to a flat line). */}
          {racePredHistory.length >= 3 && (
            <Card>
              <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 12, flexWrap: "wrap", gap: 8 }}>
                <div style={{ fontSize: 13, fontWeight: 600, color: C.textDim }}>
                  Race Time History
                  <span style={{ fontSize: 10, color: C.textMuted, marginLeft: 8, fontWeight: 400 }}>
                    ({racePredHistory.length}w · weekly snapshots from Garmin VO₂max)
                  </span>
                </div>
                <div style={{ display: "flex", gap: 4 }}>
                  {["5K", "10K", "Half Marathon", "Marathon"].map(name => {
                    const isActive = raceHistDist === name;
                    return (
                      <button key={name} onClick={() => setRaceHistDist(name)} style={{
                        padding: "5px 10px", fontSize: 11, fontWeight: isActive ? 700 : 400,
                        background: isActive ? C.cyan + "20" : "transparent",
                        color: isActive ? C.cyan : C.textMuted,
                        border: isActive ? "1px solid " + C.cyan : "1px solid " + C.border,
                        borderRadius: 6, cursor: "pointer", fontFamily: "'IBM Plex Mono',monospace",
                      }}>{name === "Half Marathon" ? "HM" : name}</button>
                    );
                  })}
                </div>
              </div>
              {(() => {
                const dataKey = raceHistDist;
                const data = racePredHistory.filter(p => p[dataKey] != null);
                // Determine the goal-line value for this distance: if the
                // active plan's race matches, use the goal time; otherwise
                // use the equivalent (Riegel) of the goal at this distance.
                let goalSec = null;
                if (activePlanSummary?.goalTime) {
                  const cleaned = String(activePlanSummary.goalTime).replace(/^sub[- ]?/i, "");
                  const parts = cleaned.split(":").map(p => parseInt(p, 10));
                  let totalGoal = null;
                  if (parts.length === 3 && parts.every(Number.isFinite)) totalGoal = parts[0] * 3600 + parts[1] * 60 + parts[2];
                  else if (parts.length === 2 && parts.every(Number.isFinite)) totalGoal = parts[0] * 3600 + parts[1] * 60;
                  if (totalGoal) {
                    const distMap = { "5K": 5000, "10K": 10000, "Half Marathon": 21097, "Marathon": 42195 };
                    const goalDistMeters = distMap[activePlanSummary.raceDistance] || 42195;
                    const targetMeters = distMap[dataKey];
                    if (goalDistMeters === targetMeters) goalSec = totalGoal;
                    else goalSec = predictFromRace(goalDistMeters, totalGoal, targetMeters);
                  }
                }
                const yMin = data.reduce((m, d) => Math.min(m, d[dataKey]), goalSec || Infinity);
                const yMax = data.reduce((m, d) => Math.max(m, d[dataKey]), goalSec || -Infinity);
                const pad = Math.max(30, (yMax - yMin) * 0.1);
                // Recharts pins ticks to the raw numeric domain endpoints
                // when handed a hard [min,max], producing a lopsided final
                // interval (e.g. 25/25/38 min). Snap the domain to a round
                // time step and pass explicit, evenly-spaced ticks so every
                // gridline gap is equal and labels land on clean values.
                const axisLo0 = Math.max(0, yMin - pad);
                const axisHi0 = yMax + pad;
                const axisSpan = Math.max(1, axisHi0 - axisLo0);
                const axisStep = [15, 30, 60, 120, 300, 600, 900, 1200, 1800]
                  .find(s => axisSpan / s <= 5) || 1800;
                const axisMin = Math.floor(axisLo0 / axisStep) * axisStep;
                const axisMax = Math.ceil(axisHi0 / axisStep) * axisStep;
                const axisTicks = [];
                if (Number.isFinite(axisMin) && Number.isFinite(axisMax) && axisStep > 0) {
                  for (let t = axisMin; t <= axisMax + 0.5 && axisTicks.length < 24; t += axisStep) axisTicks.push(t);
                }
                const fmtPaceTick = s => { const t = Math.round(s); const m = Math.floor(t / 60); return `${m}:${String(t % 60).padStart(2, "0")}`; };
                const fmtDurTick = s => {
                  const t = Math.round(s); const h = Math.floor(t / 3600), m = Math.floor((t % 3600) / 60);
                  return h > 0 ? `${h}:${String(m).padStart(2, "0")}` : `${m}:${String(t % 60).padStart(2, "0")}`;
                };
                const tickFmt = dataKey === "5K" || dataKey === "10K" ? fmtPaceTick : fmtDurTick;
                return (
                  <>
                    <ResponsiveContainer width="100%" height={240}>
                      <LineChart data={data} margin={{ top: 8, right: 24, left: 0, bottom: 0 }}>
                        <CartesianGrid strokeDasharray="3 3" stroke={C.border} vertical={false} />
                        <XAxis dataKey="label" tick={{ fill: C.textMuted, fontSize: 10 }} axisLine={false} tickLine={false} />
                        <YAxis tick={{ fill: C.textMuted, fontSize: 10 }} axisLine={false} tickLine={false}
                          domain={axisTicks.length ? [axisMin, axisMax] : [Math.max(0, yMin - pad), yMax + pad]}
                          ticks={axisTicks.length ? axisTicks : undefined} reversed
                          tickFormatter={tickFmt}
                          label={{ value: "Time", angle: -90, position: "insideLeft", fill: C.textMuted, fontSize: 10 }} />
                        <Tooltip contentStyle={{ background: C.bg2, border: `1px solid ${C.border}`, borderRadius: 8, fontSize: 11, color: C.text }}
                          formatter={(v, n) => n === dataKey ? [fmt.duration(v), n] : [v, n]}
                          labelFormatter={l => `Week of ${l}`} />
                        <Line type="monotone" dataKey={dataKey} stroke={C.cyan} strokeWidth={2} name={dataKey} connectNulls
                          dot={(props) => {
                            const { cx, cy, payload } = props;
                            if (cx == null || cy == null) return null;
                            const isCalc = payload?.source === "calc";
                            // Faded hollow dot for calc-fallback weeks so the
                            // user sees where Garmin readings began.
                            return isCalc
                              ? <circle key={`d${cx}`} cx={cx} cy={cy} r={3} fill="none" stroke={C.cyan} strokeOpacity={0.5} strokeWidth={1.5} />
                              : <circle key={`d${cx}`} cx={cx} cy={cy} r={3} fill={C.cyan} />;
                          }}
                        />
                        {goalSec && (
                          <ReferenceLine y={goalSec} stroke={C.green} strokeDasharray="6 3" strokeWidth={1.5}
                            label={{ value: `Goal ${fmt.duration(goalSec)}`, fill: C.green, fontSize: 10, position: "right" }} />
                        )}
                      </LineChart>
                    </ResponsiveContainer>
                    {(() => {
                      const calcCount = racePredHistory.filter(p => p.source === "calc").length;
                      return calcCount > 0 ? (
                        <div style={{ fontSize: 10, color: C.textMuted, marginTop: 4 }}>
                          <span style={{ display: "inline-block", width: 10, height: 10, borderRadius: 5, background: C.cyan, marginRight: 4, verticalAlign: "middle" }}></span>Garmin reading
                          <span style={{ marginLeft: 12, display: "inline-block", width: 10, height: 10, borderRadius: 5, border: `1.5px solid ${C.cyan}`, opacity: 0.5, verticalAlign: "middle" }}></span>
                          <span style={{ marginLeft: 4 }}>derived from quality runs ({calcCount}w pre-Garmin)</span>
                        </div>
                      ) : null;
                    })()}
                    <div style={{ marginTop: 6, fontSize: 10, color: C.textMuted, fontStyle: "italic", lineHeight: 1.5 }}>
                      Each point is the predicted {dataKey} time given that week's Garmin VO₂max reading.
                      {goalSec && data.length > 0 && (() => {
                        const latest = data[data.length - 1][dataKey];
                        const gap = latest - goalSec;
                        return ` Latest: ${fmt.duration(latest)} — ${gap > 0 ? `${fmt.duration(gap)} off goal` : "ahead of goal"}.`;
                      })()}
                    </div>
                  </>
                );
              })()}
            </Card>
          )}

          {/* Prediction Accuracy — backtest of the live engine against
              every completed race: prediction replayed as-of the day
              before, scored vs the actual finish. */}
          {predictionBacktest && (
            <Card>
              <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 10, flexWrap: "wrap", gap: 8 }}>
                <div style={{ fontSize: 13, fontWeight: 600, color: C.textDim }}>
                  Prediction Accuracy
                  <span style={{ fontSize: 10, color: C.textMuted, marginLeft: 8, fontWeight: 400 }}>
                    (engine replayed as-of the day before each race)
                  </span>
                </div>
                <div style={{ display: "flex", gap: 14, fontSize: 11, fontFamily: "'IBM Plex Mono', monospace" }}>
                  <span style={{ color: Math.abs(predictionBacktest.meanPct) <= 2 ? C.green : C.amber }}>
                    bias {predictionBacktest.meanPct > 0 ? "+" : ""}{predictionBacktest.meanPct}%
                  </span>
                  <span style={{ color: predictionBacktest.maePct <= 3 ? C.green : C.amber }}>
                    avg miss {predictionBacktest.maePct}%
                  </span>
                </div>
              </div>
              <div style={{ display: "grid", gridTemplateColumns: "1fr auto auto auto", gap: "4px 14px", fontSize: 12, fontFamily: "'IBM Plex Mono', monospace" }}>
                <div style={{ color: C.textMuted, fontSize: 10 }}>RACE</div>
                <div style={{ color: C.textMuted, fontSize: 10, textAlign: "right" }}>PREDICTED</div>
                <div style={{ color: C.textMuted, fontSize: 10, textAlign: "right" }}>ACTUAL</div>
                <div style={{ color: C.textMuted, fontSize: 10, textAlign: "right" }}>MISS</div>
                {predictionBacktest.rows.map((row, i) => (
                  <React.Fragment key={i}>
                    <div style={{ color: C.text, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{row.name} <span style={{ color: C.textMuted, fontSize: 10 }}>{String(row.date).slice(0, 10)}</span></div>
                    <div style={{ color: C.textDim, textAlign: "right" }}>{fmt.duration(row.predicted)}</div>
                    <div style={{ color: C.text, textAlign: "right" }}>{fmt.duration(row.actual)}</div>
                    <div style={{ color: Math.abs(row.errPct) <= 2 ? C.green : Math.abs(row.errPct) <= 4 ? C.amber : C.red, textAlign: "right" }}>{row.errPct > 0 ? "+" : ""}{Math.round(row.errPct * 10) / 10}%</div>
                  </React.Fragment>
                ))}
              </div>
              <div style={{ marginTop: 8, fontSize: 10, color: C.textMuted, fontStyle: "italic", lineHeight: 1.5 }}>
                Positive miss = you ran slower than predicted (engine optimistic). Bias within ±2% and avg miss ≤3% is excellent for race prediction. Riegel exponent uses today's calibration (includes these races), a mild hindsight bias.
              </div>
            </Card>
          )}

          {/* Goal Probability — the point prediction widened into honest
              finish bands using the backtest's OWN error distribution
              (bias + spread of past misses), not assumed variance. */}
          {predictionBacktest && predictionBacktest.rows.length >= 3 && activePlanSummary?.raceDistance && (() => {
            const distMap = { "5K": 5000, "10K": 10000, "10 Mile": 16093, "Half Marathon": 21097, "Half": 21097, "Marathon": 42195, "50K": 50000 };
            const meters = distMap[activePlanSummary.raceDistance] || 42195;
            const preds = predictRaceTimes(effectiveVO2, runs, riegelCalibration?.exponent, intentOpts);
            const pred = preds && preds.find(p => p.meters === meters);
            if (!pred) return null;
            const errs = predictionBacktest.rows.map(r => r.errPct);
            const n = errs.length;
            const mu = errs.reduce((s, e) => s + e, 0) / n;
            const sd = Math.sqrt(errs.reduce((s, e) => s + (e - mu) * (e - mu), 0) / (n - 1)) * Math.sqrt(1 + 1 / n);
            if (!(sd > 0.2)) return null;
            const at = (z) => Math.round(pred.seconds * (1 + (mu + z * sd) / 100));
            const erf = (x) => { const s = x < 0 ? -1 : 1; x = Math.abs(x); const t = 1 / (1 + 0.3275911 * x); const y = 1 - (((((1.061405429 * t - 1.453152027) * t) + 1.421413741) * t - 0.284496736) * t + 0.254829592) * t * Math.exp(-x * x); return s * y; };
            const cdf = (z) => 0.5 * (1 + erf(z / Math.SQRT2));
            const cleaned = String(activePlanSummary.goalTime || "").replace(/^sub[- ]?/i, "");
            const gp = cleaned.split(":").map(Number);
            const goalSec = gp.length === 3 && gp.every(Number.isFinite) ? gp[0] * 3600 + gp[1] * 60 + gp[2] : gp.length === 2 && gp.every(Number.isFinite) ? gp[0] * 60 + gp[1] : null;
            const pSub = goalSec ? Math.round(cdf(((goalSec / pred.seconds - 1) * 100 - mu) / sd) * 100) : null;
            const pCol = pSub == null ? C.textMuted : pSub >= 65 ? C.green : pSub >= 40 ? C.amber : C.red;
            return (
              <Card>
                <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 10, flexWrap: "wrap", gap: 8 }}>
                  <div style={{ fontSize: 13, fontWeight: 600, color: C.textDim }}>
                    Goal Probability
                    <span style={{ fontSize: 10, color: C.textMuted, marginLeft: 8, fontWeight: 400 }}>({activePlanSummary.raceDistance} · bands from your {n} backtested races)</span>
                  </div>
                  {pSub != null && (
                    <div style={{ fontSize: 16, fontWeight: 800, fontFamily: "'Space Grotesk', sans-serif", color: pCol }}>{pSub}% <span style={{ fontSize: 11, fontWeight: 400, color: C.textMuted }}>chance of {activePlanSummary.goalTime}</span></div>
                  )}
                </div>
                <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 10, textAlign: "center", fontFamily: "'IBM Plex Mono', monospace" }}>
                  {[["Great day (10%)", at(-1.2816), C.green], ["Median", at(0), C.text], ["Rough day (90%)", at(1.2816), C.amber]].map(([lab, sec, col]) => (
                    <div key={lab} style={{ background: C.bg2, borderRadius: 8, padding: "10px 6px" }}>
                      <div style={{ fontSize: 9, color: C.textMuted, textTransform: "uppercase", letterSpacing: "0.05em" }}>{lab}</div>
                      <div style={{ fontSize: 16, fontWeight: 700, color: col, marginTop: 4 }}>{fmt.duration(sec)}</div>
                    </div>
                  ))}
                </div>
                <div style={{ marginTop: 8, fontSize: 10, color: C.textMuted, fontStyle: "italic", lineHeight: 1.5 }}>
                  The point prediction widened by how the engine has actually missed on your races (bias {predictionBacktest.meanPct > 0 ? "+" : ""}{predictionBacktest.meanPct}%, spread ±{Math.round(sd * 10) / 10}%). 10%/90% = one-in-ten best/worst days. Tightens as more races accumulate.
                </div>
              </Card>
            );
          })()}

          {/* Fitness & Fatigue — Banister impulse-response model. CTL
              (42-day weighted load) = fitness, ATL (7-day) = fatigue,
              TSB = form (yesterday's fitness − fatigue). The classic
              "am I peaking or digging a hole" chart: form goes negative
              through a hard block, then climbs through the taper into
              the +5..+25 race-ready window. */}
          {fitnessFatigueModel && fitnessFatigueModel.points.length >= 14 && (() => {
            const ffm = fitnessFatigueModel;
            const chartData = ffm.points.slice(-120).map(p => ({
              label: p.date.slice(5).replace("-", "/"), ctl: p.ctl, atl: p.atl, tsb: p.tsb,
            }));
            const cur = ffm.current;
            const tsbColor = cur.tsb >= 5 ? C.green : cur.tsb >= -30 ? C.purple : C.red;
            return (
              <Card>
                <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 12, flexWrap: "wrap", gap: 8 }}>
                  <div style={{ fontSize: 13, fontWeight: 600, color: C.textDim }}>
                    Fitness &amp; Fatigue
                    <span style={{ fontSize: 10, color: C.textMuted, marginLeft: 8, fontWeight: 400 }}>
                      (Banister model · intensity-weighted TRIMP, all sports)
                    </span>
                  </div>
                  <div style={{ display: "flex", gap: 14, fontSize: 11, fontFamily: "'IBM Plex Mono', monospace" }}>
                    <span style={{ color: C.cyan }}>Fitness {cur.ctl}</span>
                    <span style={{ color: C.amber }}>Fatigue {cur.atl}</span>
                    <span style={{ color: tsbColor, fontWeight: 700 }}>Form {cur.tsb > 0 ? "+" : ""}{cur.tsb}</span>
                  </div>
                </div>
                <ResponsiveContainer width="100%" height={240}>
                  <ComposedChart data={chartData} margin={{ top: 8, right: 16, left: -8, bottom: 0 }}>
                    <CartesianGrid strokeDasharray="3 3" stroke={C.border} vertical={false} />
                    <XAxis dataKey="label" tick={{ fill: C.textMuted, fontSize: 10 }} axisLine={false} tickLine={false} minTickGap={28} />
                    <YAxis tick={{ fill: C.textMuted, fontSize: 10 }} axisLine={false} tickLine={false} />
                    <Tooltip contentStyle={{ background: C.bg2, border: `1px solid ${C.border}`, borderRadius: 8, fontSize: 11, color: C.text }}
                      formatter={(val, name) => [val, name]} />
                    <ReferenceLine y={0} stroke={C.border} strokeWidth={1} />
                    <Area type="monotone" dataKey="tsb" name="Form (TSB)" stroke={C.purple} strokeWidth={1.5} fill={C.purple} fillOpacity={0.12} dot={false} />
                    <Line type="monotone" dataKey="ctl" name="Fitness (CTL)" stroke={C.cyan} strokeWidth={2} dot={false} />
                    <Line type="monotone" dataKey="atl" name="Fatigue (ATL)" stroke={C.amber} strokeWidth={1.5} dot={false} />
                  </ComposedChart>
                </ResponsiveContainer>
                <div style={{ marginTop: 6, fontSize: 10, color: C.textMuted, fontStyle: "italic", lineHeight: 1.5 }}>
                  Form (TSB) = fitness − fatigue. Negative mid-build is productive training; +5..+25 on race morning is the target. A hard block drops form; the taper converts it.
                  {ffm.acwr && <> EWMA load ratio {ffm.acwr.ratio} ({ffm.acwr.flag}).</>}
                  {ffm.monotony && ffm.monotony.monotony > 2 && <> Monotony {ffm.monotony.monotony} — vary hard/easy days.</>}
                </div>
              </Card>
            );
          })()}

          {/* Durability — aerobic decoupling (Pa:HR drift) on long runs.
              The strongest modern marathon-readiness signal: <5% on the
              long stuff means pace and HR stayed coupled deep into the
              run; a falling trend across a build is endurance arriving. */}
          {(() => {
            const toD = (x) => x.start_date?.toDate ? x.start_date.toDate() : new Date(x.start_date);
            const pts = runs
              .filter(r => (r.distance >= 10 * 1609.34) || ((r.moving_time || 0) >= 90 * 60))
              .map(r => ({ d: toD(r), dec: r.dynamics?.aerobicDecouplingPct, mi: r.distance / 1609.34 }))
              .filter(p => Number.isFinite(p.dec) && !isNaN(p.d.getTime()))
              .sort((a, b) => a.d - b.d)
              .slice(-24)
              .map(p => ({ date: `${p.d.getMonth() + 1}/${p.d.getDate()}`, dec: Math.round(p.dec * 10) / 10, mi: Math.round(p.mi * 10) / 10 }));
            if (pts.length < 3) return null;
            const recent = pts.slice(-5);
            const avgRecent = Math.round(recent.reduce((s, p) => s + p.dec, 0) / recent.length * 10) / 10;
            const avgColor = avgRecent < 5 ? C.green : avgRecent < 10 ? C.amber : C.red;
            return (
              <Card>
                <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 12, flexWrap: "wrap", gap: 8 }}>
                  <div style={{ fontSize: 13, fontWeight: 600, color: C.textDim }}>
                    Durability
                    <span style={{ fontSize: 10, color: C.textMuted, marginLeft: 8, fontWeight: 400 }}>(aerobic decoupling on long runs — last {pts.length})</span>
                  </div>
                  <div style={{ fontSize: 11, fontFamily: "'IBM Plex Mono', monospace", color: avgColor }}>last 5 avg {avgRecent}%</div>
                </div>
                <ResponsiveContainer width="100%" height={200}>
                  <BarChart data={pts} margin={{ top: 8, right: 16, left: -8, bottom: 0 }}>
                    <CartesianGrid strokeDasharray="3 3" stroke={C.border} vertical={false} />
                    <XAxis dataKey="date" tick={{ fill: C.textMuted, fontSize: 10 }} minTickGap={24} axisLine={false} tickLine={false} />
                    <YAxis tick={{ fill: C.textMuted, fontSize: 10 }} unit="%" axisLine={false} tickLine={false} />
                    <Tooltip contentStyle={{ background: C.bg2, border: `1px solid ${C.border}`, borderRadius: 8, fontSize: 11, color: C.text }}
                      formatter={(v, n, p) => [`${v}% drift · ${p?.payload?.mi}mi`, "decoupling"]} />
                    <ReferenceLine y={5} stroke={C.green} strokeDasharray="6 3" label={{ value: "strong <5%", fill: C.green, fontSize: 9, position: "right" }} />
                    <ReferenceLine y={10} stroke={C.amber} strokeDasharray="6 3" />
                    <Bar dataKey="dec" radius={[3, 3, 0, 0]}>
                      {pts.map((p, i) => <Cell key={i} fill={p.dec < 5 ? C.green : p.dec < 10 ? C.amber : C.red} fillOpacity={0.75} />)}
                    </Bar>
                  </BarChart>
                </ResponsiveContainer>
                <div style={{ marginTop: 6, fontSize: 10, color: C.textMuted, fontStyle: "italic", lineHeight: 1.5 }}>
                  Decoupling = how much pace:HR efficiency decayed from the first half to the second. Under 5% on 16+ mi runs is the classic "marathon-ready" signal; a falling trend across the build is endurance arriving. Heat and hills inflate it — read trends, not single runs.
                </div>
              </Card>
            );
          })()}

          {/* Cycle Comparison — current race-prep cycle vs a previous one,
              aligned by weeks-to-race rather than calendar date. Answers
              the rear-view-mirror coaching question: "at 8 weeks out for
              Philly I was at 45 mi/wk; today for NYC I'm at 32 mi/wk —
              I'm 4 weeks behind where I was." */}
          {pastRaces.length > 0 && activePlanSummary?.raceDate && (() => {
            // Multi-overlay: cycleCmpRaceId is now treated as a
            // comma-separated string of past race ids ("philly,shamrock")
            // backed by the existing single-key state — keeps the
            // state-shape compatible while supporting N comparisons.
            const selectedIds = (cycleCmpRaceId || "").split(",").filter(Boolean);
            // Default to the most recent past marathon when nothing's
            // selected (preserves the prior single-overlay behavior).
            const effectiveSelectedIds = selectedIds.length > 0
              ? selectedIds
              : (pastRaces[0] ? [pastRaces[0].id] : []);
            const selectedRaces = effectiveSelectedIds
              .map(id => pastRaces.find(r => r.id === id))
              .filter(Boolean);

            const weeksOut = 16;
            const allRuns = (activities || []).filter(a => a.type === "Run" || a.type === "VirtualRun");
            const isQualityFn = (r) => {
              const intent = classifyRunIntent(r);
              return intent === "race" || intent === "hard";
            };
            const currentSeries = window.CycleComparison.buildCycleSeries({
              raceDate: activePlanSummary.raceDate, runs: allRuns, weeksOut, isQualityFn,
            });
            // Distinct color per comparison cycle. Cycle through the
            // palette deterministically based on selection order.
            const palette = [C.amber, "#84cc16", C.purple || "#a78bfa", C.pink || "#f472b6", "#06b6d4"];
            const labelOf = (r) => r.name || `${r.distance} ${new Date(r.date).toLocaleDateString("en-US", { month: "short", year: "numeric" })}`;
            const comparisons = selectedRaces.map((r, i) => ({
              id: r.id,
              label: labelOf(r),
              color: palette[i % palette.length],
              series: window.CycleComparison.buildCycleSeries({
                raceDate: r.date, runs: allRuns, weeksOut, isQualityFn,
              }),
            }));
            const aligned = window.CycleComparison.alignCyclesMulti(currentSeries, comparisons, { weeksOut });

            const metricLabels = {
              mileage: { label: "Weekly Mileage", unit: "mi", color: C.cyan },
              longestRun: { label: "Long Run", unit: "mi", color: C.green },
              qualityRunCount: { label: "Quality Runs", unit: "/wk", color: C.amber },
              runCount: { label: "Runs", unit: "/wk", color: C.purple || "#a78bfa" },
            };
            const m = metricLabels[cycleCmpMetric];

            // Build chart data — current under "current" key, each
            // comparison under its id.
            const chartData = aligned.map(a => {
              const row = {
                wtr: a.weeksToRace,
                label: a.weeksToRace === 0 ? "Race" : `-${a.weeksToRace}w`,
                current: a.current ? a.current[cycleCmpMetric] : null,
              };
              for (const c of comparisons) {
                row[c.id] = a.comparisons[c.id] ? a.comparisons[c.id][cycleCmpMetric] : null;
              }
              return row;
            });

            // Cumulative totals for the summary strip.
            const sumOver = (key) => aligned.reduce((s, a) => {
              const cell = key === "current" ? a.current : a.comparisons[key];
              return s + (cell ? (cell[cycleCmpMetric] || 0) : 0);
            }, 0);
            const currentTotal = sumOver("current");

            // Toggle a race in/out of the selection.
            const togglePill = (raceId) => {
              const next = effectiveSelectedIds.includes(raceId)
                ? effectiveSelectedIds.filter(id => id !== raceId)
                : [...effectiveSelectedIds, raceId];
              setCycleCmpRaceId(next.join(","));
            };

            return (
              <Card>
                <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 8, flexWrap: "wrap", gap: 8 }}>
                  <div style={{ fontSize: 13, fontWeight: 600, color: C.textDim }}>
                    Cycle Comparison
                    <span style={{ fontSize: 10, color: C.textMuted, marginLeft: 8, fontWeight: 400 }}>
                      Aligned by weeks-to-race · {comparisons.length} comparison cycle{comparisons.length === 1 ? "" : "s"}
                    </span>
                  </div>
                  <div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
                    {Object.entries(metricLabels).map(([key, info]) => {
                      const active = cycleCmpMetric === key;
                      return (
                        <button key={key} onClick={() => setCycleCmpMetric(key)} style={{
                          padding: "4px 10px", fontSize: 11, fontWeight: active ? 700 : 400,
                          background: active ? info.color + "20" : "transparent",
                          color: active ? info.color : C.textMuted,
                          border: active ? "1px solid " + info.color : "1px solid " + C.border,
                          borderRadius: 6, cursor: "pointer", fontFamily: "'IBM Plex Mono',monospace",
                        }}>{info.label}</button>
                      );
                    })}
                  </div>
                </div>

                {/* Race-pill toggles — click to add/remove a comparison cycle */}
                {pastRaces.length > 0 && (
                  <div style={{ display: "flex", gap: 6, flexWrap: "wrap", marginBottom: 10 }}>
                    {pastRaces.map((r, i) => {
                      const active = effectiveSelectedIds.includes(r.id);
                      const cmpIdx = comparisons.findIndex(c => c.id === r.id);
                      const pillColor = active && cmpIdx >= 0 ? palette[cmpIdx % palette.length] : C.textMuted;
                      return (
                        <button key={r.id} onClick={() => togglePill(r.id)} style={{
                          padding: "3px 10px", fontSize: 11, fontWeight: active ? 700 : 400,
                          background: active ? pillColor + "20" : "transparent",
                          color: active ? pillColor : C.textMuted,
                          border: active ? `1px solid ${pillColor}` : `1px solid ${C.border}`,
                          borderRadius: 12, cursor: "pointer", fontFamily: "'IBM Plex Mono',monospace",
                        }}>
                          {labelOf(r)}
                        </button>
                      );
                    })}
                  </div>
                )}

                {/* Cumulative summary strip — current + each comparison + Δ vs each */}
                <div style={{ display: "flex", gap: 16, fontSize: 11, color: C.textMuted, marginBottom: 12, flexWrap: "wrap" }}>
                  <span>Current (16w): <span style={{ color: C.cyan, fontWeight: 700 }}>{currentTotal.toFixed(1)}{m.unit}</span></span>
                  {comparisons.map(c => {
                    const t = sumOver(c.id);
                    const delta = currentTotal - t;
                    return (
                      <span key={c.id}>
                        {c.label}: <span style={{ color: c.color, fontWeight: 700 }}>{t.toFixed(1)}{m.unit}</span>
                        {t > 0 && (
                          <span style={{ marginLeft: 4 }}>(Δ <span style={{ color: delta >= 0 ? C.green : C.red, fontWeight: 700 }}>
                            {delta >= 0 ? "+" : ""}{delta.toFixed(1)}
                          </span>)</span>
                        )}
                      </span>
                    );
                  })}
                </div>

                <ResponsiveContainer width="100%" height={240}>
                  <LineChart data={chartData} margin={{ top: 8, right: 16, left: -8, bottom: 0 }}>
                    <CartesianGrid strokeDasharray="3 3" stroke={C.border} vertical={false} />
                    <XAxis dataKey="label" tick={{ fill: C.textMuted, fontSize: 10 }} axisLine={false} tickLine={false}
                      label={{ value: "weeks to race", fill: C.textMuted, fontSize: 10, position: "insideBottom", offset: -4 }} />
                    <YAxis tick={{ fill: C.textMuted, fontSize: 10 }} axisLine={false} tickLine={false}
                      tickFormatter={v => v + m.unit} />
                    <Tooltip contentStyle={{ background: C.bg2, border: `1px solid ${C.border}`, borderRadius: 8, fontSize: 11, color: C.text }}
                      formatter={(v, n) => [v != null ? `${v}${m.unit}` : "—", n]} />
                    <Legend wrapperStyle={{ fontSize: 11, color: C.textMuted }} />
                    <Line type="monotone" dataKey="current" stroke={C.cyan} strokeWidth={2.5} dot={{ r: 3, fill: C.cyan }} name="Current cycle" connectNulls />
                    {comparisons.map(c => (
                      <Line key={c.id} type="monotone" dataKey={c.id} stroke={c.color} strokeWidth={2}
                        strokeDasharray="6 3" dot={{ r: 3, fill: c.color }} name={c.label} connectNulls />
                    ))}
                  </LineChart>
                </ResponsiveContainer>

                <div style={{ marginTop: 6, fontSize: 10, color: C.textMuted, fontStyle: "italic", lineHeight: 1.5 }}>
                  All cycles aligned by weeks-to-race — race week is W-0, far left is 16 weeks out. Click a race pill to toggle it on/off the chart. {m.label} on the y-axis.
                </div>
              </Card>
            );
          })()}

          {/* Recent Workouts Reviewed — coach-style fidelity grading
              of the last quality + long sessions in the active plan.
              Pulls each plan day with a linkedActivityId, matches the
              activity, runs gradeWorkout (pure helper) and renders an
              A-D grade with reasoning. Drops the page from "data
              viewer" toward "actually coach-like." */}
          {activePlanSummary?.weeks?.length > 0 && (() => {
            // Walk the plan, collect quality/long plan days paired with the
            // run that executed them. The activity comes from either an
            // explicit linkedActivityId OR — for any past, non-skipped day —
            // an auto-match on that plan-day's calendar date (longest run on
            // the day = the session). Previously this only followed
            // linkedActivityId, so the panel was frozen on the one workout
            // the athlete had ever manually linked.
            const QUALITY_TYPES = new Set(["tempo", "threshold", "intervals", "interval", "race_pace", "race", "long"]);
            const DAY_OFFSET = { Monday: 0, Tuesday: 1, Wednesday: 2, Thursday: 3, Friday: 4, Saturday: 5, Sunday: 6 };
            const isRunAct = (a) => a && (a.type === "Run" || a.type === "VirtualRun");
            const actMs = (a) => {
              const t = a?.start_date_local || a?.start_date;
              return (typeof t === "string") ? Date.parse(t)
                : (t?.toDate ? t.toDate().getTime() : (t?.seconds ? t.seconds * 1000 : 0));
            };
            const w1Start = window.PlanMath?.planWeek1Start?.(activePlanSummary);
            const nowMs = Date.now();
            const linked = [];
            (activePlanSummary.weeks || []).forEach((week, wi) => {
              (week?.days || []).forEach(d => {
                if (d.skipped) return;
                if (!QUALITY_TYPES.has((d.type || "").toLowerCase())) return;
                // 1. Manual link wins.
                let act = d.linkedActivityId ? (activities || []).find(a => String(a.id) === String(d.linkedActivityId)) : null;
                // 2. Else auto-match a run on this plan-day's date (only once
                //    that date is in the past — don't pre-grade future days).
                if (!act && w1Start) {
                  const lo = w1Start.getTime() + (wi * 7 + (DAY_OFFSET[d.day] ?? 0)) * 86400000;
                  const hi = lo + 86400000;
                  if (lo < nowMs) {
                    const cands = (activities || []).filter(a => isRunAct(a) && a.distance > 0 && actMs(a) >= lo && actMs(a) < hi);
                    act = cands.sort((a, b) => b.distance - a.distance)[0] || null;
                  }
                }
                if (!isRunAct(act)) return;
                linked.push({ planDay: d, activity: act, weekNum: wi + 1, _ms: actMs(act) });
              });
            });
            // Dedupe by activity id (a run could match more than one plan day
            // in pathological cases), newest first, take 5.
            const seenAct = new Set();
            const reviewed = linked
              .sort((a, b) => b._ms - a._ms)
              .filter(({ activity }) => { const id = String(activity.id); if (seenAct.has(id)) return false; seenAct.add(id); return true; })
              .slice(0, 5);
            if (!reviewed.length) return null;

            const hrZonesData = getHRZones(hrZoneConfig, activities, hrMethod, userHRConfig);
            const fmtDate = (ms) => new Date(ms).toLocaleDateString("en-US", { month: "short", day: "numeric" }); // gradeColor: module-level

            const ex = trainingSnapshot?.execution || null;
            return (
              <Card>
                <div style={{ display: "flex", alignItems: "baseline", justifyContent: "space-between", flexWrap: "wrap", gap: 8, marginBottom: 10 }}>
                  <div style={{ fontSize: 13, fontWeight: 600, color: C.textDim }}>
                    Recent Workouts Reviewed
                    <span style={{ fontSize: 10, color: C.textMuted, marginLeft: 8, fontWeight: 400 }}>
                      Last {reviewed.length} quality + long sessions, graded A-D on pace/zone fidelity
                    </span>
                  </div>
                  <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
                    {ex && ex.gradedQualitySessions > 0 && (
                      <span style={{ fontSize: 11, fontWeight: 700, color: metricColor(ex.onTargetPct, "onTargetPct"), fontFamily: "'IBM Plex Mono', monospace" }}
                        title="From the shared training snapshot — the same figure shown on Status.">
                        {ex.onTargetCount}/{ex.gradedQualitySessions} on-target ({ex.onTargetPct}%)
                      </span>
                    )}
                    {isOwner && askCoach && reviewed.length > 0 && (
                      <button onClick={() => askCoach(`Look at my last ${reviewed.length} quality/long sessions — what's the pattern, and what should the next block emphasise?`)}
                        style={{ background: C.purple + "14", border: `1px solid ${C.purple}33`, color: C.purple, borderRadius: 6, padding: "3px 8px", fontSize: 10, fontWeight: 600, cursor: "pointer", fontFamily: "'IBM Plex Mono', monospace", whiteSpace: "nowrap" }}>
                        💬 Ask the coach
                      </button>
                    )}
                  </div>
                </div>
                <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
                  {reviewed.map(({ planDay, activity, weekNum, _ms }) => {
                    const review = window.WorkoutExecution.gradeWorkout({
                      planDay, activity, hrZones: hrZonesData?.zones || [],
                    });
                    if (!review) return null;
                    const gC = gradeColor(review.grade);
                    return (
                      <div key={String(activity.id)} style={{ padding: "10px 12px", background: C.bg2, borderRadius: 8, display: "flex", gap: 12, alignItems: "flex-start" }}>
                        <div style={{ flex: "0 0 auto", textAlign: "center", minWidth: 40 }}>
                          <div style={{ fontSize: 24, fontWeight: 800, color: gC, fontFamily: "'Space Grotesk',sans-serif", lineHeight: 1 }}>
                            {review.grade || "—"}
                          </div>
                          <div style={{ fontSize: 9, color: C.textMuted, marginTop: 2 }}>{fmtDate(_ms)}</div>
                          <div style={{ fontSize: 9, color: C.textMuted }}>W{weekNum}</div>
                        </div>
                        <div style={{ flex: 1, minWidth: 0 }}>
                          <div style={{ fontSize: 12, color: C.text, lineHeight: 1.5 }}>
                            {review.comment}
                          </div>
                          {review.hr?.grade && (
                            <div style={{ fontSize: 10, color: C.textMuted, marginTop: 4 }}>
                              HR band: <span style={{ color: review.hr.inBand ? C.green : C.amber }}>{review.hr.label}</span>
                              {review.pace?.label && review.pace.label !== "no-target" && (
                                <span> · Avg pace: <span style={{ color: C.textMuted }}>{review.pace.label}</span></span>
                              )}
                            </div>
                          )}
                          {!review.hr?.grade && (review.pace?.label && review.pace.label !== "no-target") && (
                            <div style={{ fontSize: 10, color: C.textMuted, marginTop: 4 }}>
                              Pace: <span style={{ color: review.pace.grade === "A" ? C.green : review.pace.grade === "D" ? C.red : C.text }}>{review.pace.label}</span>
                              {review.zone?.label && review.zone.label !== "no-hr" && review.zone.label !== "no-zone" && (
                                <span> · Zone: <span style={{ color: review.zone.inZone ? C.green : C.amber }}>{review.zone.label}</span></span>
                              )}
                            </div>
                          )}
                          {!review.hr?.grade && (!review.pace || review.pace.label === "no-target") && review.zone?.label && review.zone.label !== "no-hr" && review.zone.label !== "no-zone" && (
                            <div style={{ fontSize: 10, color: C.textMuted, marginTop: 4 }}>
                              Zone: <span style={{ color: review.zone.inZone ? C.green : C.amber }}>{review.zone.label}</span>
                            </div>
                          )}
                        </div>
                      </div>
                    );
                  })}
                </div>
                <div style={{ marginTop: 8, fontSize: 10, color: C.textMuted, fontStyle: "italic", lineHeight: 1.5 }}>
                  Tempo / threshold workouts graded on avg pace adherence vs targetPace (±5s/mi = A, ±15 = B, ±30 = C). Intervals / race-pace graded on whether avg HR landed inside the day's targetHR band (overall avg pace of a workout that mixes warmup + reps + recovery doesn't represent execution). Easy / long / recovery graded on the polarized zone. Distance gates apply (short or overcooked drops the grade).
                </div>
              </Card>
            );
          })()}

          {/* Today's Wellness — 30-second daily 1-10 self-rating
              that flows into the Telegram coach context. The
              "should I push or back off today?" judgement needs the
              subjective signal alongside Garmin's objective metrics
              (research consistently shows subjective inputs catch
              overtraining earlier than HRV alone). Pre-fills with
              yesterday's values to make daily entry one-tap. */}
          {(() => {
            // Pure-component-style block — internal state for the
            // entry form, fetched docs from a small useEffect below.
            const [wellnessDocs, setWellnessDocs] = useState([]);
            const [wellnessForm, setWellnessForm] = useState({ sleep: 7, soreness: 4, motivation: 7, stress: 4, note: "" });
            const [wellnessSaving, setWellnessSaving] = useState(false);
            const [wellnessSavedAt, setWellnessSavedAt] = useState(null);
            const todayISO = new Date().toISOString().split("T")[0];
            const yesterdayISO = new Date(Date.now() - 86400000).toISOString().split("T")[0];

            useEffect(() => {
              if (!userId) return;
              const cutoff = new Date(Date.now() - 14 * 86400000).toISOString().split("T")[0];
              const unsub = db.collection("users").doc(userId).collection("wellness")
                .orderBy(firebase.firestore.FieldPath.documentId(), "asc")
                .startAt(cutoff)
                .onSnapshot(snap => {
                  const docs = snap.docs.map(d => ({ date: d.id, ...d.data() }));
                  setWellnessDocs(docs);
                  // Pre-fill the form with today's entry if present, else yesterday's.
                  const today = docs.find(d => d.date === todayISO);
                  const yest = docs.find(d => d.date === yesterdayISO);
                  const seed = today || yest;
                  if (seed) {
                    setWellnessForm({
                      sleep: seed.sleep ?? 7,
                      soreness: seed.soreness ?? 4,
                      motivation: seed.motivation ?? 7,
                      stress: seed.stress ?? 4,
                      note: today?.note ?? "",
                    });
                  }
                });
              return () => unsub && unsub();
            }, [userId, todayISO, yesterdayISO]);

            const todayEntry = wellnessDocs.find(d => d.date === todayISO);
            // Composite + interpretation match the lib's math.
            const HIGHER = new Set(["sleep", "motivation"]);
            const composite = (e) => {
              if (!e) return null;
              const fields = ["sleep", "soreness", "motivation", "stress"];
              let s = 0, n = 0;
              for (const k of fields) {
                const v = e[k];
                if (!Number.isFinite(v) || v < 1 || v > 10) continue;
                s += HIGHER.has(k) ? v : (11 - v);
                n += 1;
              }
              if (!n) return null;
              return Math.round(((s / n) - 1) / 9 * 100);
            };
            const interpret = (s) => s == null ? null : s >= 80 ? "Ready" : s >= 60 ? "Decent" : s >= 40 ? "Compromised" : "Depleted";
            const tagColor = (s) => s == null ? C.textMuted : s >= 80 ? C.green : s >= 60 ? C.cyan : s >= 40 ? C.amber : C.red;
            const todayScore = composite(todayEntry);
            const tagText = interpret(todayScore);

            const submit = async () => {
              setWellnessSaving(true);
              try {
                const fn = functions.httpsCallable("logWellness");
                await fn({ ...wellnessForm, date: todayISO });
                setWellnessSavedAt(Date.now());
              } catch (e) { console.warn("[wellness] save failed:", e.message); }
              setWellnessSaving(false);
            };

            const Slider = ({ label, value, lowerIsBetter, onChange }) => (
              <div style={{ marginBottom: 8 }}>
                <div style={{ display: "flex", justifyContent: "space-between", fontSize: 11, marginBottom: 3 }}>
                  <span style={{ color: C.textDim, fontWeight: 600 }}>{label}{lowerIsBetter ? " (lower better)" : ""}</span>
                  <span style={{ color: C.text, fontWeight: 700, fontFamily: "'IBM Plex Mono',monospace" }}>{value}/10</span>
                </div>
                <input type="range" min="1" max="10" step="1" value={value}
                  onChange={e => onChange(parseInt(e.target.value, 10))}
                  style={{ width: "100%", accentColor: C.cyan }} />
              </div>
            );

            return (
              <Card>
                <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 10, flexWrap: "wrap", gap: 8 }}>
                  <div style={{ fontSize: 13, fontWeight: 600, color: C.textDim }}>
                    Today's Wellness
                    <span style={{ fontSize: 10, color: C.textMuted, marginLeft: 8, fontWeight: 400 }}>
                      30-second subjective check · feeds the coach context
                    </span>
                  </div>
                  {todayScore != null && (
                    <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
                      <span style={{ fontSize: 22, fontWeight: 800, fontFamily: "'Space Grotesk',sans-serif", color: tagColor(todayScore) }}>{todayScore}</span>
                      <span style={{ fontSize: 10, padding: "2px 8px", borderRadius: 4, background: tagColor(todayScore) + "20", color: tagColor(todayScore), fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.05em" }}>{tagText}</span>
                    </div>
                  )}
                </div>

                {isOwner && (
                  <div style={{ padding: "10px 12px", background: C.bg2, borderRadius: 8 }}>
                    <Slider label="Sleep quality"  value={wellnessForm.sleep}      lowerIsBetter={false} onChange={v => setWellnessForm(f => ({ ...f, sleep: v }))} />
                    <Slider label="Soreness"       value={wellnessForm.soreness}   lowerIsBetter={true}  onChange={v => setWellnessForm(f => ({ ...f, soreness: v }))} />
                    <Slider label="Motivation"     value={wellnessForm.motivation} lowerIsBetter={false} onChange={v => setWellnessForm(f => ({ ...f, motivation: v }))} />
                    <Slider label="Life stress"    value={wellnessForm.stress}     lowerIsBetter={true}  onChange={v => setWellnessForm(f => ({ ...f, stress: v }))} />
                    <input type="text" placeholder="Optional note (e.g. 'tight calves after long run')"
                      value={wellnessForm.note}
                      onChange={e => setWellnessForm(f => ({ ...f, note: e.target.value }))}
                      maxLength={280}
                      style={{ width: "100%", marginTop: 6, padding: "6px 10px", background: C.bg3, border: `1px solid ${C.border}`, borderRadius: 6, color: C.text, fontSize: 12 }} />
                    <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginTop: 8 }}>
                      <span style={{ fontSize: 10, color: C.textMuted }}>
                        {todayEntry ? `Saved today — pre-filled from your last entry. Adjust + save to update.`
                          : "Pre-filled from yesterday — adjust if needed and save."}
                      </span>
                      <button onClick={submit} disabled={wellnessSaving}
                        style={{ padding: "6px 14px", background: C.cyan, border: "none", borderRadius: 6, color: C.bg, fontWeight: 700, fontSize: 12, cursor: wellnessSaving ? "wait" : "pointer", opacity: wellnessSaving ? 0.6 : 1 }}>
                        {wellnessSaving ? "Saving…" : (todayEntry ? "Update" : "Log")}
                      </button>
                    </div>
                    {wellnessSavedAt && (
                      <div style={{ fontSize: 10, color: C.green, marginTop: 4 }}>✓ Saved · the coach will see this on the next chat turn.</div>
                    )}
                  </div>
                )}

                {wellnessDocs.length >= 3 && (() => {
                  const recent = wellnessDocs.slice(-7);
                  const scores = recent.map(e => composite(e)).filter(s => s != null);
                  if (scores.length < 3) return null;
                  const mean = scores.reduce((s, v) => s + v, 0) / scores.length;
                  return (
                    <div style={{ marginTop: 8, fontSize: 11, color: C.textMuted, lineHeight: 1.5 }}>
                      <span>7d mean: <span style={{ color: tagColor(Math.round(mean)), fontWeight: 700 }}>{Math.round(mean)}/100</span></span>
                      {todayScore != null && (
                        <span style={{ marginLeft: 12 }}>
                          Today vs 7d: <span style={{ color: todayScore >= mean ? C.green : C.amber, fontWeight: 700 }}>
                            {todayScore >= mean ? "+" : ""}{todayScore - Math.round(mean)}
                          </span>
                        </span>
                      )}
                    </div>
                  );
                })()}
              </Card>
            );
          })()}

          {/* Race Course Profile lives under the Race tab now (per-race
              card alongside each upcoming race). Removed from Summary
              to keep this tab focused on training-block status. */}

          {/* Recent Long Runs — race-day-rehearsal detection +
              MP-finish classification. Marathon prep is won on the
              long run; this card distinguishes "easy aerobic
              builder" from "MP simulation" from "race-day rehearsal"
              so the rest of the week's recovery load can be planned
              accordingly. */}
          {(() => {
            const longs = (activities || [])
              .filter(a => a.type === "Run" || a.type === "VirtualRun")
              .filter(a => a.distance && a.distance >= 12 * 1609.34)
              .map(a => {
                const t = a.start_date_local || a.start_date;
                const ms = (typeof t === "string") ? Date.parse(t)
                  : (t?.toDate ? t.toDate().getTime() : (t?.seconds ? t.seconds * 1000 : 0));
                return { ...a, _ms: ms };
              })
              .sort((a, b) => b._ms - a._ms)
              .slice(0, 5);
            if (!longs.length) return null;
            const planForAnalysis = activePlanSummary ? {
              goalTime: activePlanSummary.goalTime,
              raceDistance: activePlanSummary.raceDistance,
            } : null;
            const fmtDate = (ms) => new Date(ms).toLocaleDateString("en-US", { month: "short", day: "numeric" });
            const classColor = (c) => c === "race-day-rehearsal" ? C.cyan
              : c === "marathon-pace-simulation" ? C.amber
              : c === "moderate-long" ? C.green
              : c === "easy-long" ? C.textMuted
              : C.textMuted;

            return (
              <Card>
                <div style={{ fontSize: 13, fontWeight: 600, color: C.textDim, marginBottom: 10 }}>
                  Recent Long Runs
                  <span style={{ fontSize: 10, color: C.textMuted, marginLeft: 8, fontWeight: 400 }}>
                    {longs.length} long run{longs.length === 1 ? "" : "s"} · classified vs marathon-pace target
                  </span>
                </div>
                <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
                  {longs.map(a => {
                    const review = window.LongRunQuality.analyzeLongRun({
                      activity: a,
                      plan: planForAnalysis,
                      laps: a.laps || null,
                    });
                    if (!review) return null;
                    const cC = classColor(review.classification);
                    return (
                      <div key={String(a.id) + a._ms} style={{ padding: "10px 12px", background: C.bg2, borderRadius: 8, display: "flex", gap: 12, alignItems: "flex-start" }}>
                        <div style={{ flex: "0 0 auto", textAlign: "center", minWidth: 56 }}>
                          <div style={{ fontSize: 9, color: C.textMuted }}>{fmtDate(a._ms)}</div>
                          <div style={{ fontSize: 16, fontWeight: 800, color: cC, fontFamily: "'Space Grotesk',sans-serif", lineHeight: 1, marginTop: 2 }}>
                            {review.distanceMi}<span style={{ fontSize: 10, color: C.textMuted, fontWeight: 400 }}>mi</span>
                          </div>
                        </div>
                        <div style={{ flex: 1, minWidth: 0 }}>
                          <div style={{ fontSize: 10, fontWeight: 700, color: cC, letterSpacing: "0.05em", textTransform: "uppercase", marginBottom: 3 }}>
                            {review.classification?.replace(/-/g, " ") || "long run"}
                          </div>
                          <div style={{ fontSize: 12, color: C.text, lineHeight: 1.5 }}>
                            {review.coachComment || a.name}
                          </div>
                          {review.structure && (
                            <div style={{ fontSize: 10, color: C.textMuted, marginTop: 3 }}>
                              Structure: <span style={{ color: review.structure.structure === "fading" ? C.red : C.text, fontWeight: 600 }}>{review.structure.structure}</span>
                              {review.structure.mpFinishMi >= 1 && (
                                <span> · {review.structure.mpFinishMi}mi at MP closing</span>
                              )}
                            </div>
                          )}
                        </div>
                      </div>
                    );
                  })}
                </div>
                <div style={{ marginTop: 8, fontSize: 10, color: C.textMuted, fontStyle: "italic", lineHeight: 1.5 }}>
                  Race-day rehearsal = ≥{window.LongRunQuality.RACE_REHEARSAL_MIN_MI}mi within ±15s/mi of MP. MP simulation = ±30s/mi. Structure detection (progressive / fast-finish / mp-finish / fading) requires lap data — click into an activity to fetch it.
                </div>
              </Card>
            );
          })()}

          {/* Lactate Thresholds (LT1 / LT2) */}
          {ltEstimates && (
            <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(300px, 1fr))", gap: 16 }}>
              <Card>
                <div style={{ fontSize: 13, fontWeight: 600, color: C.textDim, marginBottom: 14 }}>Lactate Thresholds</div>
                {/* LTHR refresh banner — auto-population is one-shot
                    so once the configured value lands it never updates,
                    even if Garmin re-measures threshold (which it does
                    every few weeks on intervals/tempos). When Garmin's
                    current reading differs from the configured value by
                    ≥3 bpm, surface a one-click apply. */}
                {(() => {
                  const garminLTHR = garminStats?.lactateThrHR && garminStats.lactateThrHR > 100
                    ? Math.round(garminStats.lactateThrHR) : null;
                  const cfgLTHR = userHRConfig?.lthr || null;
                  if (!garminLTHR || !cfgLTHR) return null;
                  const delta = garminLTHR - cfgLTHR;
                  if (Math.abs(delta) < 3) return null;
                  const direction = delta > 0 ? "higher" : "lower";
                  const sev = Math.abs(delta) >= 8 ? C.amber : C.cyan;
                  return (
                    <div style={{ marginBottom: 12, padding: "10px 12px", background: sev + "10", border: `1px solid ${sev}40`, borderRadius: 8, display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap" }}>
                      <span style={{ fontSize: 11, color: C.text, flex: 1, minWidth: 200, lineHeight: 1.4 }}>
                        Garmin's current measurement is <strong style={{ color: sev }}>{garminLTHR} bpm</strong> — {Math.abs(delta)} bpm {direction} than your configured {cfgLTHR}. Threshold drifts as fitness changes, so the older value is likely stale.
                      </span>
                      {isOwner && (
                        <button onClick={() => saveHRConfig({ ...userHRConfig, lthr: garminLTHR })}
                          style={{ padding: "6px 12px", borderRadius: 6, border: `1px solid ${sev}`, background: sev + "20", color: sev, fontSize: 11, fontWeight: 700, cursor: "pointer", whiteSpace: "nowrap" }}>
                          Update to {garminLTHR}
                        </button>
                      )}
                    </div>
                  );
                })()}
                <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
                  {/* LT1 */}
                  <div style={{ padding: 16, background: C.bg2, borderRadius: 10, borderLeft: `3px solid ${C.green}` }}>
                    <div style={{ fontSize: 11, fontWeight: 700, color: C.green, textTransform: "uppercase", letterSpacing: "0.05em", marginBottom: 8 }}>LT1 — Aerobic</div>
                    <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
                      <div>
                        <div style={{ fontSize: 24, fontWeight: 800, fontFamily: "'Space Grotesk', sans-serif", color: C.text }}>{ltEstimates.lt1.hr} <span style={{ fontSize: 12, fontWeight: 400, color: C.textMuted }}>bpm</span></div>
                        <div style={{ fontSize: 10, color: C.textMuted }}>{ltEstimates.lt1.hrPct}% maxHR · {ltEstimates.lt1.vo2pct}% VO2max</div>
                      </div>
                      {ltEstimates.lt1.pace && (
                        <div>
                          <div style={{ fontSize: 18, fontWeight: 700, fontFamily: "'Space Grotesk', sans-serif", color: C.green }}>{fmt.pace(ltEstimates.lt1.pace)}</div>
                          <div style={{ fontSize: 10, color: C.textMuted }}>Pace at LT1</div>
                        </div>
                      )}
                    </div>
                    <div style={{ marginTop: 8, fontSize: 10, color: C.textMuted, lineHeight: 1.5 }}>
                      Top of easy/conversational effort. Fat oxidation peaks here. Stay below for 80% of training.
                    </div>
                  </div>
                  {/* LT2 */}
                  <div style={{ padding: 16, background: C.bg2, borderRadius: 10, borderLeft: `3px solid ${C.amber}` }}>
                    <div style={{ fontSize: 11, fontWeight: 700, color: C.amber, textTransform: "uppercase", letterSpacing: "0.05em", marginBottom: 8 }}>LT2 — Anaerobic</div>
                    <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
                      <div>
                        <div style={{ fontSize: 24, fontWeight: 800, fontFamily: "'Space Grotesk', sans-serif", color: C.text }}>{ltEstimates.lt2.hr} <span style={{ fontSize: 12, fontWeight: 400, color: C.textMuted }}>bpm</span></div>
                        <div style={{ fontSize: 10, color: C.textMuted }}>{ltEstimates.lt2.hrPct}% maxHR · {ltEstimates.lt2.vo2pct}% VO2max</div>
                      </div>
                      {ltEstimates.lt2.pace && (
                        <div>
                          <div style={{ fontSize: 18, fontWeight: 700, fontFamily: "'Space Grotesk', sans-serif", color: C.amber }}>{fmt.pace(ltEstimates.lt2.pace)}</div>
                          <div style={{ fontSize: 10, color: C.textMuted }}>Pace at LT2 (threshold)</div>
                        </div>
                      )}
                    </div>
                    <div style={{ marginTop: 8, fontSize: 10, color: C.textMuted, lineHeight: 1.5 }}>
                      Lactate accumulates above this. Tempo/threshold runs target this intensity. Critical for marathon performance.
                    </div>
                  </div>
                </div>
                <div style={{ marginTop: 12, padding: "8px 12px", background: C.bg2, borderRadius: 8, fontSize: 11, color: C.textMuted }}>
                  {(() => {
                    const hrSrc = ltEstimates.sources?.lt2HR === "garmin"
                      ? "Garmin-measured LTHR"
                      : ltEstimates.sources?.lt2HR === "empirical-override"
                        ? `${ltEstimates.lt2.hr} (recent quality efforts override stale ${userHRConfig?.lthr} user-set)`
                        : ltEstimates.sources?.lt2HR === "empirical"
                          ? `${ltEstimates.lt2.hr} (75th-percentile HR of recent quality efforts)`
                          : ltEstimates.sources?.lt2HR === "user-set"
                            ? `${userHRConfig?.lthr} (user-configured LTHR)`
                            : `${Math.round(summaryMaxHR * 0.91)} (91% × ${summaryMaxHR} maxHR — set LTHR in HR config for a tighter estimate)`;
                    const paceSrc = {
                      "garmin": "Garmin-measured threshold pace",
                      "empirical": "tempo-end (30th-percentile) pace of recent runs at LT2 HR ± 5 bpm — biased fast so HR-drift on long easy runs doesn't pull the estimate slow",
                      "predicted-hm": `predicted Half Marathon pace (VO2max ${effectiveVO2})`,
                      "predicted-mp": `Marathon pace × 0.96 (VO2max ${effectiveVO2})`,
                      "none": "—",
                    }[ltEstimates.sources?.lt2Pace] || "derived";
                    const lt1PaceSrc = {
                      "empirical": " LT1 pace from your runs at LT1 HR.",
                      "predicted-mp": " LT1 pace from predicted marathon pace.",
                      "floored-vs-lt2": " LT1 pace anchored to LT2 (an easier effort must run slower).",
                    }[ltEstimates.sources?.lt1Pace] || "";
                    return `LT2 HR from ${hrSrc}. LT2 pace from ${paceSrc}. LT1 placed ${ltEstimates.sources?.lt2HR === "derived" ? "by 82% × maxHR" : "14 bpm below LT2"}.${lt1PaceSrc}`;
                  })()}
                </div>
              </Card>

              {/* LT Progress Chart */}
              {ltHistory.length >= 2 && (
                <Card>
                  <div style={{ fontSize: 13, fontWeight: 600, color: C.textDim, marginBottom: 14 }}>LT Progress {ltHistory[0]?.label ? `— since ${ltHistory[0].label}` : ""}</div>
                  <ResponsiveContainer width="100%" height={240}>
                    <ComposedChart data={ltHistory}>
                      <CartesianGrid strokeDasharray="3 3" stroke={C.border} vertical={false} />
                      <XAxis dataKey="label" tick={{ fill: C.textMuted, fontSize: 10 }} axisLine={false} tickLine={false} />
                      <YAxis yAxisId="hr" tick={{ fill: C.textMuted, fontSize: 10 }} axisLine={false} tickLine={false} domain={["dataMin - 2", "dataMax + 2"]}
                        label={{ value: "HR (bpm)", angle: -90, position: "insideLeft", fill: C.textMuted, fontSize: 10 }} />
                      <YAxis yAxisId="pace" orientation="right" tick={{ fill: C.textMuted, fontSize: 10 }} axisLine={false} tickLine={false}
                        domain={["dataMin - 10", "dataMax + 10"]} reversed
                        tickFormatter={v => { const m = Math.floor(v / 60); const s = Math.round(v % 60); return `${m}:${s.toString().padStart(2, "0")}`; }}
                        label={{ value: "Pace (/mi)", angle: 90, position: "insideRight", fill: C.textMuted, fontSize: 10 }} />
                      <Tooltip contentStyle={{ background: C.bg2, border: `1px solid ${C.border}`, borderRadius: 8, fontSize: 11, color: C.text }}
                        formatter={(v, name) => {
                          if (name.includes("Pace")) { const m = Math.floor(v/60); const s = Math.round(v%60); return [`${m}:${s.toString().padStart(2,"0")} /mi`, name]; }
                          return [name.includes("HR") ? `${v} bpm` : v, name];
                        }} />
                      <Legend wrapperStyle={{ fontSize: 11, color: C.textMuted }} />
                      <Line yAxisId="hr" type="monotone" dataKey="lt1HR" stroke={C.green} strokeWidth={2} dot={{ r: 3 }} name="LT1 HR" connectNulls />
                      <Line yAxisId="hr" type="monotone" dataKey="lt2HR" stroke={C.amber} strokeWidth={2} dot={{ r: 3 }} name="LT2 HR" connectNulls />
                      <Line yAxisId="pace" type="monotone" dataKey="lt2Pace" stroke={C.cyan} strokeWidth={2} strokeDasharray="6 3" name="LT2 Pace" connectNulls
                        dot={(props) => {
                          const { cx, cy, payload } = props;
                          if (cx == null || cy == null) return null;
                          const isCalc = payload?.source === "calc";
                          return isCalc
                            ? <circle key={`lt${cx}`} cx={cx} cy={cy} r={3} fill="none" stroke={C.cyan} strokeOpacity={0.5} strokeWidth={1.5} />
                            : <circle key={`lt${cx}`} cx={cx} cy={cy} r={3} fill={C.cyan} />;
                        }}
                      />
                      {/* Anchor lines for the current measured/configured
                          values, so the chart's per-week derivations can be
                          read against the present-day truth. The historical
                          line is empirical/predicted (Garmin doesn't carry
                          a per-week LTHR/pace history), so the anchor
                          lines tell the runner where they actually are
                          right now. */}
                      {ltEstimates?.lt2?.pace && (
                        <ReferenceLine yAxisId="pace" y={ltEstimates.lt2.pace} stroke={C.cyan} strokeDasharray="2 2" strokeOpacity={0.6}
                          label={{ value: `Current LT2: ${fmt.pace(ltEstimates.lt2.pace).replace(' /mi','')}`, fill: C.cyan, fontSize: 9, position: "insideTopLeft" }} />
                      )}
                      {ltEstimates?.lt2?.hr && (
                        <ReferenceLine yAxisId="hr" y={ltEstimates.lt2.hr} stroke={C.amber} strokeDasharray="2 2" strokeOpacity={0.5} />
                      )}
                    </ComposedChart>
                  </ResponsiveContainer>
                  {(() => {
                    const calcCount = ltHistory.filter(p => p.source === "calc").length;
                    return calcCount > 0 ? (
                      <div style={{ fontSize: 10, color: C.textMuted, marginTop: 4 }}>
                        <span style={{ display: "inline-block", width: 10, height: 10, borderRadius: 5, background: C.cyan, marginRight: 4, verticalAlign: "middle" }}></span>LT2 Pace from Garmin VO₂max
                        <span style={{ marginLeft: 12, display: "inline-block", width: 10, height: 10, borderRadius: 5, border: `1.5px solid ${C.cyan}`, opacity: 0.5, verticalAlign: "middle" }}></span>
                        <span style={{ marginLeft: 4 }}>from quality runs ({calcCount}w pre-Garmin)</span>
                      </div>
                    ) : null;
                  })()}
                  <div style={{ marginTop: 8, fontSize: 10, color: C.textMuted, fontStyle: "italic" }}>
                    Per-week values use the Garmin VO₂max reading on or before that Monday (falling back to a trailing 90d window of quality runs if no Garmin reading exists). Dotted reference lines show your current measured/configured LT2 — what the panel above is showing.
                  </div>
                </Card>
              )}
            </div>
          )}

          {/* Gait Profile — derived from Garmin run-dynamics. Tells the
              shoe-pick logic (and the runner) what kind of stride
              they have. No new input — all from existing per-activity
              dynamics already on the activities collection. */}
          {(() => {
            const allRuns = (activities || []).filter(a => a.type === "Run" || a.type === "VirtualRun");
            const gait = window.GaitProfile.buildGaitProfile({ runs: allRuns });
            if (!gait || !gait.runCount) return null;
            const cadColor = gait.cadenceClass === "high" ? C.green : gait.cadenceClass === "average" ? C.amber : C.red;
            const effColor = gait.efficiencyClass === "efficient" ? C.green : gait.efficiencyClass === "average" ? C.amber : C.red;
            const Stat = ({ label, value, unit, sub, color }) => (
              <div style={{ flex: "1 1 110px", padding: "10px 12px", background: C.bg2, borderRadius: 8, minWidth: 110 }}>
                <div style={{ fontSize: 10, color: C.textMuted, fontWeight: 700, letterSpacing: "0.05em", textTransform: "uppercase", marginBottom: 4 }}>{label}</div>
                <div style={{ fontSize: 18, fontWeight: 800, fontFamily: "'Space Grotesk',sans-serif", color: color || C.text }}>
                  {value != null ? value : "—"}
                  {value != null && unit && <span style={{ fontSize: 11, color: C.textMuted, fontWeight: 400, marginLeft: 4 }}>{unit}</span>}
                </div>
                {sub && <div style={{ fontSize: 10, color: C.textMuted, marginTop: 2 }}>{sub}</div>}
              </div>
            );
            return (
              <Card>
                <div style={{ fontSize: 13, fontWeight: 600, color: C.textDim, marginBottom: 10 }}>
                  Gait Profile
                  <span style={{ fontSize: 10, color: C.textMuted, marginLeft: 8, fontWeight: 400 }}>
                    (median across {gait.runCount} runs in last {gait.windowDays}d{gait.fastRunCount >= 3 ? `, fast = top ${gait.fastRunCount} by pace` : ""} · from Garmin run dynamics)
                  </span>
                </div>
                <div style={{ display: "flex", gap: 10, flexWrap: "wrap" }}>
                  <Stat label="Cadence" value={gait.cadenceSpm} unit="spm" color={cadColor}
                    sub={
                      gait.cadenceSpmFast != null && gait.fastRunCount >= 3
                        ? `${gait.cadenceClass} · fast: ${gait.cadenceSpmFast} spm`
                        : (gait.cadenceClass ? `${gait.cadenceClass} (${gait.cadenceSpmP25}-${gait.cadenceSpmP75})` : null)
                    } />
                  <Stat label="GCT" value={gait.groundContactTimeMs} unit="ms"
                    sub={
                      gait.groundContactTimeMsFast != null && gait.fastRunCount >= 3
                        ? `fast: ${gait.groundContactTimeMsFast} ms`
                        : (gait.groundContactTimeMs != null ? `${gait.groundContactTimeMsP25}-${gait.groundContactTimeMsP75}` : null)
                    } />
                  <Stat label="Vert Osc" value={gait.verticalOscillationCm} unit="cm"
                    sub={gait.verticalOscillationCmFast != null && gait.fastRunCount >= 3 ? `fast: ${gait.verticalOscillationCmFast} cm` : null} />
                  <Stat label="Vert Ratio" value={gait.verticalRatioPct} unit="%" color={effColor}
                    sub={
                      gait.verticalRatioPctFast != null && gait.fastRunCount >= 3
                        ? `${gait.efficiencyClass} · fast: ${gait.verticalRatioPctFast}%`
                        : gait.efficiencyClass
                    } />
                  <Stat label="GCT Balance" value={gait.gctBalance} unit="% L"
                    sub={gait.gctBalance != null ? `${(gait.gctBalance - 50).toFixed(1)}% from symmetric` : null} />
                  <Stat label="Strike Bias" value={gait.strikeBias} unit=""
                    sub={gait.fastRunCount >= 3 ? "inferred at fast pace" : "inferred from cad+GCT+vert osc"} />
                </div>
                {(() => {
                  // Form Challenges & Suggestions — coach-style readout of
                  // which metrics fall outside healthy ranges, with one
                  // targeted drill / cue per issue. Keeps the suggestion
                  // surface honest: only shows what's actually flagged,
                  // not generic "do strides every week" boilerplate.
                  const issues = [];
                  // Pace-aware: if we have ≥3 fast runs, judge cadence /
                  // GCT / vert ratio off the fast-pace median. Cadence and
                  // stride length both rise with pace, so a long-legged
                  // runner whose easy cadence sits at 165 but who turns
                  // over at 182 at threshold should NOT see "low cadence"
                  // here. Only flag issues that persist at race effort.
                  const hasFast = gait.fastRunCount >= 3;
                  const judgeCad = hasFast && gait.cadenceSpmFast != null ? gait.cadenceSpmFast : gait.cadenceSpm;
                  const judgeGct = hasFast && gait.groundContactTimeMsFast != null ? gait.groundContactTimeMsFast : gait.groundContactTimeMs;
                  const judgeVR = hasFast && gait.verticalRatioPctFast != null ? gait.verticalRatioPctFast : gait.verticalRatioPct;
                  const paceQual = hasFast ? " at fast pace" : "";
                  if (judgeCad != null && judgeCad < 170) {
                    issues.push({
                      label: `Low cadence (${judgeCad} spm${paceQual})`,
                      fix: "Add 6×30s strides 2× / week at ~180 spm. Use a metronome on easy runs to nudge turnover up — short, quick steps cut overstriding.",
                    });
                  }
                  if (judgeGct != null && judgeGct > 280) {
                    issues.push({
                      label: `Long ground contact (${judgeGct} ms${paceQual})`,
                      fix: "Plyometrics 1×/week (pogo hops, A-skips, bounding 3×20m). Shorter contact ≈ more elastic recoil; the calf/Achilles needs to learn quicker rebound.",
                    });
                  }
                  if (judgeVR != null && judgeVR >= 9) {
                    issues.push({
                      label: `High vertical ratio (${judgeVR}%${paceQual})`,
                      fix: "Forward-lean cue + 6×30s hill strides. You're losing energy upward — drive forward off the ankle, not up off the knee.",
                    });
                  }
                  if (gait.gctBalance != null && Math.abs(gait.gctBalance - 50) > 1.5) {
                    const heavySide = gait.gctBalance > 50 ? "left" : "right";
                    issues.push({
                      label: `L/R asymmetry (${gait.gctBalance}% L)`,
                      fix: `Heavier on ${heavySide}. Single-leg work 2×/week (RDL, step-ups, calf raises) on the lighter side. Persistent >1.5% drift correlates with overuse on the loaded side.`,
                    });
                  }
                  if (!issues.length) {
                    return (
                      <div style={{ marginTop: 12, padding: "8px 12px", background: C.green + "10", border: `1px solid ${C.green}33`, borderRadius: 8, fontSize: 11, color: C.green }}>
                        No form challenges flagged — cadence, GCT, efficiency, and L/R symmetry all within healthy ranges.
                      </div>
                    );
                  }
                  return (
                    <div style={{ marginTop: 12, padding: "10px 12px", background: C.amber + "10", border: `1px solid ${C.amber}33`, borderRadius: 8 }}>
                      <div style={{ fontSize: 10, color: C.amber, fontWeight: 700, letterSpacing: "0.05em", textTransform: "uppercase", marginBottom: 6 }}>
                        Form Challenges & Suggestions
                      </div>
                      {issues.map((iss, i) => (
                        <div key={i} style={{ fontSize: 11, color: C.text, marginBottom: i < issues.length - 1 ? 6 : 0, lineHeight: 1.5 }}>
                          <span style={{ color: C.amber, fontWeight: 600 }}>{iss.label}</span>
                          <span style={{ color: C.textDim }}> — {iss.fix}</span>
                        </div>
                      ))}
                    </div>
                  );
                })()}
                <div style={{ marginTop: 10, fontSize: 10, color: C.textMuted, fontStyle: "italic", lineHeight: 1.5 }}>
                  Used to bias the shoe-pick logic and (later) shopping recommendations. High cadence + low GCT + low vert osc suits low-stack low-drop shoes; low cadence + heel bias does better with cushioned high-drop trainers.
                </div>
              </Card>
            );
          })()}

          {/* Weekly Training Dimensions — endurance / VO2 / hills /
              speed / aerobic / anaerobic / form scoring across the
              last 4 completed weeks. Showing the in-progress current
              week was misleading: on a Friday it reads "19mi total"
              and the dimensions look terrible because half the week
              hasn't happened yet. We pull 5 weeks back, drop weeks[0]
              (the partial current week), and treat weeks[1] — the
              most recently completed Mon-Sun — as "current". Trend
              and persistent-deficit analysis run on the same 4
              completed weeks. */}
          {(() => {
            if (!window.DeficitAnalysis) return null;
            const allRuns = (activities || []).filter(a => a.type === "Run" || a.type === "VirtualRun");
            if (!allRuns.length) return null;
            // Derive easyHrCap from the athlete's actual Z1 ceiling so
            // aerobic % isn't computed against the hardcoded default
            // 145 bpm. When LTHR is correct, zones[0].maxBPM ≈ Z1 cap.
            const easyHrCap = athleteProfile?.zones?.[0]?.maxBPM
              || (athleteProfile?.maxHR ? Math.round(athleteProfile.maxHR * 0.82) : 145);
            const allWeeks = window.DeficitAnalysis.analyzeWeeks({ runs: allRuns, weeksBack: 5, easyHrCap });
            if (!allWeeks || allWeeks.length < 2) return null;
            const weeks = allWeeks.slice(1); // drop in-progress current week
            const persistent = window.DeficitAnalysis.persistentDeficit(weeks, { threshold: 35 });
            const trends = window.DeficitAnalysis.dimensionTrends(weeks);
            const cur = weeks[0];
            const dimOrder = ["endurance", "vo2", "hills", "speed", "aerobic", "anaerobic", "form"];
            const labels = { endurance: "Endurance", vo2: "VO₂", hills: "Hills", speed: "Speed", aerobic: "Aerobic", anaerobic: "Anaerobic", form: "Form" };
            const colorFor = (s) => s == null ? C.textMuted : s >= 70 ? C.green : s >= 40 ? C.amber : C.red;
            const trendArrow = (t) => t === "improving" ? "↑" : t === "declining" ? "↓" : t === "stable" ? "→" : "";
            const trendColor = (t) => t === "improving" ? C.green : t === "declining" ? C.red : C.textMuted;
            return (
              <Card>
                <div style={{ fontSize: 13, fontWeight: 600, color: C.textDim, marginBottom: 10 }}>
                  Weekly Training Dimensions
                  <span style={{ fontSize: 10, color: C.textMuted, marginLeft: 8, fontWeight: 400 }}>
                    (last completed week · trend vs earlier weeks)
                  </span>
                </div>
                <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(140px, 1fr))", gap: 8 }}>
                  {dimOrder.map(d => {
                    const dim = cur.dimensions[d];
                    const score = dim?.score;
                    const trend = trends[d];
                    return (
                      <div key={d} style={{ padding: "10px 12px", background: C.bg2, borderRadius: 8 }}>
                        <div style={{ fontSize: 10, color: C.textMuted, fontWeight: 700, letterSpacing: "0.05em", textTransform: "uppercase", marginBottom: 4, display: "flex", justifyContent: "space-between" }}>
                          <span>{labels[d]}</span>
                          {trend && trend !== "unknown" && (
                            <span style={{ color: trendColor(trend), fontWeight: 700 }}>{trendArrow(trend)}</span>
                          )}
                        </div>
                        <div style={{ fontSize: 18, fontWeight: 800, fontFamily: "'Space Grotesk',sans-serif", color: colorFor(score) }}>
                          {score == null ? "—" : score}
                          {score != null && <span style={{ fontSize: 11, color: C.textMuted, fontWeight: 400, marginLeft: 4 }}>/100</span>}
                        </div>
                        <div style={{ fontSize: 10, color: C.textMuted, marginTop: 2, lineHeight: 1.4 }}>
                          {dim?.evidence || ""}
                        </div>
                      </div>
                    );
                  })}
                </div>
                {persistent && (
                  <div style={{ marginTop: 12, padding: "10px 12px", background: "rgba(239, 68, 68, 0.08)", border: `1px solid ${C.red}33`, borderRadius: 8 }}>
                    <div style={{ fontSize: 10, color: C.red, fontWeight: 700, letterSpacing: "0.05em", textTransform: "uppercase", marginBottom: 6 }}>
                      Persistent Deficit · {labels[persistent.dimension]} · {persistent.weeksRunning} wks
                    </div>
                    <div style={{ fontSize: 11, color: C.text, lineHeight: 1.6 }}>
                      Suggested drills:
                      <ul style={{ margin: "4px 0 0 18px", padding: 0 }}>
                        {persistent.drills.map((dr, i) => <li key={i} style={{ marginBottom: 2 }}>{dr}</li>)}
                      </ul>
                    </div>
                  </div>
                )}
                {!persistent && cur.weakest && cur.weakest.score < 50 && (
                  <div style={{ marginTop: 12, padding: "10px 12px", background: "rgba(245, 158, 11, 0.08)", border: `1px solid ${C.amber}33`, borderRadius: 8 }}>
                    <div style={{ fontSize: 10, color: C.amber, fontWeight: 700, letterSpacing: "0.05em", textTransform: "uppercase", marginBottom: 6 }}>
                      This week's weakest · {labels[cur.weakest.dimension]} · {cur.weakest.score}/100
                    </div>
                    <div style={{ fontSize: 11, color: C.text, lineHeight: 1.6 }}>
                      Suggested drills:
                      <ul style={{ margin: "4px 0 0 18px", padding: 0 }}>
                        {cur.weakest.drills.map((dr, i) => <li key={i} style={{ marginBottom: 2 }}>{dr}</li>)}
                      </ul>
                    </div>
                  </div>
                )}
                <div style={{ marginTop: 10, fontSize: 10, color: C.textMuted, fontStyle: "italic", lineHeight: 1.5 }}>
                  Same deficit analysis the Telegram coach sees. ↑ improving · → stable · ↓ declining (vs prior weeks). Form is n/a when no Garmin run-dynamics data this week.
                </div>
              </Card>
            );
          })()}

          {/* Footwear + Shoe Recommendations now live on the dedicated
              Shoes tab — see <Shoes /> above Settings. The Summary tab
              keeps a small pointer for muscle memory. */}
          {(() => {
            const haveGear = Array.isArray(gearList) && gearList.length > 0;
            return (
              <Card>
                <div style={{ fontSize: 13, fontWeight: 600, color: C.textDim, marginBottom: 6 }}>
                  Footwear
                </div>
                <div style={{ fontSize: 11, color: C.textDim, lineHeight: 1.6 }}>
                  {haveGear
                    ? <>Your shoe rotation, retirement bars, race-day pick, and shopping recs live on the <strong style={{ color: C.cyan }}>Shoes</strong> tab.</>
                    : <>No shoes synced yet. Open <strong style={{ color: C.cyan }}>Settings → Strava → Sync Now</strong>, then check the <strong style={{ color: C.cyan }}>Shoes</strong> tab.</>}
                </div>
              </Card>
            );
          })()}

          {/* HR Zones with BPM ranges + methodology toggle */}
          {(() => {
            const hrData = getHRZones(hrZoneConfig, activities, hrMethod, userHRConfig);
            const methodInfo = HR_METHODOLOGIES[hrMethod];
            const methodKeys = Object.keys(HR_METHODOLOGIES);
            // Which metrics this methodology needs
            const needsRestHR = hrMethod === "karvonen";
            const needsLTHR = hrMethod === "five_zone_coggan";
            const needsAge = hrMethod === "maf";
            return (
              <Card>
                <div style={{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:6,flexWrap:"wrap",gap:6}}>
                  <div style={{display:"flex",alignItems:"center",gap:10,flexWrap:"wrap"}}>
                    <div style={{fontSize:13,fontWeight:600,color:C.textDim}}>Heart Rate Zones</div>
                    <NewBadge snap={latestHr} />
                  </div>
                  <div style={{display:"flex",alignItems:"center",gap:8,flexWrap:"wrap"}}>
                    <div style={{fontSize:11,color:C.textMuted}}>
                      Max HR: <span style={{color:C.text,fontWeight:600}}>{hrData.maxHR} bpm</span>
                    </div>
                    {hrData.maxHRSource === "observed" && (
                      <span style={{fontSize:9,padding:"2px 6px",borderRadius:4,background:C.amber+"20",color:C.amber,fontWeight:600}}>AUTO-DETECTED</span>
                    )}
                    {hrData.maxHRSource === "user-set" && (
                      <span style={{fontSize:9,padding:"2px 6px",borderRadius:4,background:C.cyan+"20",color:C.cyan,fontWeight:600}}>USER SET</span>
                    )}
                    {hrData.maxHRSource === "age-estimated" && (
                      <span style={{fontSize:9,padding:"2px 6px",borderRadius:4,background:C.textMuted+"20",color:C.textMuted,fontWeight:600}}>220−AGE ESTIMATE</span>
                    )}
                    {hrHistory.length > 0 && (
                      <button
                        onClick={() => setHrHistoryOpen(o => !o)}
                        title={hrHistoryOpen ? "Hide history" : "Show zone history"}
                        style={{
                          padding:"4px 10px", borderRadius:5, border:`1px solid ${C.border}`,
                          background:"transparent", color:C.textDim,
                          fontSize:11, fontWeight:600, cursor:"pointer",
                          fontFamily:"'IBM Plex Mono',monospace", whiteSpace:"nowrap",
                        }}>
                        {hrHistoryOpen ? "▾ History" : "▸ History"}
                      </button>
                    )}
                    {isOwner && requestPlanRefresh && (
                      <button
                        onClick={() => requestPlanRefresh()}
                        title="Smart-Refresh every non-locked week so each prescribed targetHR / hrZone matches the zones shown here. Completed / skipped / manually-edited days stay locked."
                        style={{
                          padding:"4px 10px", borderRadius:5, border:`1px solid ${C.amber}40`,
                          background:"transparent", color:C.amber,
                          fontSize:11, fontWeight:600, cursor:"pointer",
                          fontFamily:"'IBM Plex Mono',monospace", whiteSpace:"nowrap",
                        }}>
                        ↺ Refresh plan to these zones
                      </button>
                    )}
                  </div>
                </div>
                {hrHistoryOpen && (
                  <div style={{ padding: "10px 14px", background: C.bg2, borderRadius: 8, marginBottom: 12, borderLeft: `3px solid ${C.cyan}` }}>
                    <div style={{ fontSize: 11, fontWeight: 600, color: C.textDim, marginBottom: 6 }}>HR zone history</div>
                    <ZoneHistoryChart snapshots={hrHistory} type="hr" />
                  </div>
                )}

                {/* Methodology toggle tabs */}
                <div style={{display:"flex",gap:4,marginBottom:12,flexWrap:"wrap"}}>
                  {methodKeys.map(key => {
                    const m = HR_METHODOLOGIES[key];
                    const isActive = hrMethod === key;
                    return (
                      <button key={key} onClick={() => { setHrMethod(key); if (isOwner && userId) db.collection("users").doc(userId).collection("settings").doc("hrConfig").set({ hrMethod: key }, { merge: true }); }} style={{
                        padding:"5px 10px",fontSize:11,fontWeight:isActive?700:400,
                        background:isActive ? C.cyan+"20" : "transparent",
                        color:isActive ? C.cyan : C.textMuted,
                        border: isActive ? "1px solid "+C.cyan : "1px solid "+C.border,
                        borderRadius:6,cursor:"pointer",fontFamily:"'IBM Plex Mono',monospace",
                        transition:"all 0.15s",
                      }}>{m.shortName}</button>
                    );
                  })}
                </div>

                {/* Methodology explanation */}
                <div style={{padding:"10px 14px",background:C.bg2,borderRadius:8,marginBottom:14,borderLeft:"3px solid "+C.cyan}}>
                  <div style={{fontSize:12,fontWeight:600,color:C.text,marginBottom:4}}>{methodInfo.name}</div>
                  <div style={{fontSize:11,color:C.textDim,lineHeight:1.5,marginBottom:6}}>{methodInfo.description}</div>
                  <div style={{display:"flex",gap:16,fontSize:10}}>
                    <div><span style={{color:C.green,fontWeight:600}}>Best for: </span><span style={{color:C.textMuted}}>{methodInfo.bestFor}</span></div>
                  </div>
                  <div style={{marginTop:4,fontSize:10}}>
                    <span style={{color:C.amber,fontWeight:600}}>Limitation: </span><span style={{color:C.textMuted}}>{methodInfo.limitation}</span>
                  </div>
                  <button onClick={() => setShowHRConfig(!showHRConfig)} style={{
                    marginTop:8, fontSize:10, color:C.cyan, background:"none", border:"none", cursor:"pointer",
                    fontFamily:"'IBM Plex Mono',monospace", padding:0, textDecoration:"underline",
                  }}>{showHRConfig ? "Hide config" : "Configure metrics"}</button>
                </div>

                {/* Configurable HR Metrics */}
                {showHRConfig && (
                  <div style={{padding:"12px 14px",background:C.bg2,borderRadius:8,marginBottom:14,display:"grid",gridTemplateColumns:"repeat(auto-fit,minmax(120px,1fr))",gap:10}}>
                    <div>
                      <label style={{fontSize:10,color:C.textMuted,display:"block",marginBottom:3}}>Max HR</label>
                      <input type="number" value={userHRConfig.maxHR || ""} onChange={e => saveHRConfig({...userHRConfig, maxHR: parseInt(e.target.value) || null})}
                        placeholder="e.g. 187" style={{width:"100%",padding:"6px 8px",borderRadius:5,border:`1px solid ${C.border}`,background:C.bg,color:C.text,fontSize:12,fontFamily:"'IBM Plex Mono',monospace",boxSizing:"border-box"}} />
                    </div>
                    <div>
                      <label style={{fontSize:10,color:needsRestHR ? C.cyan : C.textMuted,display:"block",marginBottom:3}}>
                        Resting HR {needsRestHR && "*"}
                      </label>
                      <input type="number" value={userHRConfig.restingHR || ""} onChange={e => saveHRConfig({...userHRConfig, restingHR: parseInt(e.target.value) || null})}
                        placeholder="auto" style={{width:"100%",padding:"6px 8px",borderRadius:5,border:`1px solid ${needsRestHR ? C.cyan : C.border}`,background:C.bg,color:C.text,fontSize:12,fontFamily:"'IBM Plex Mono',monospace",boxSizing:"border-box"}} />
                      <div style={{fontSize:9,color:C.textMuted,marginTop:2}}>Used by Karvonen (HRR)</div>
                    </div>
                    <div>
                      <label style={{fontSize:10,color:needsLTHR ? C.cyan : C.textMuted,display:"block",marginBottom:3}}>
                        LTHR {needsLTHR && "*"}
                      </label>
                      <input type="number" value={userHRConfig.lthr || ""} onChange={e => saveHRConfig({...userHRConfig, lthr: parseInt(e.target.value) || null})}
                        placeholder="auto" style={{width:"100%",padding:"6px 8px",borderRadius:5,border:`1px solid ${needsLTHR ? C.cyan : C.border}`,background:C.bg,color:C.text,fontSize:12,fontFamily:"'IBM Plex Mono',monospace",boxSizing:"border-box"}} />
                      <div style={{fontSize:9,color:C.textMuted,marginTop:2}}>30-min TT avg HR. Used by Coggan.</div>
                    </div>
                    <div>
                      <label style={{fontSize:10,color:needsAge ? C.cyan : C.textMuted,display:"block",marginBottom:3}}>
                        Age {needsAge && "*"}
                      </label>
                      <input type="number" value={userHRConfig.age || ""} onChange={e => saveHRConfig({...userHRConfig, age: parseInt(e.target.value) || null})}
                        placeholder="43" style={{width:"100%",padding:"6px 8px",borderRadius:5,border:`1px solid ${needsAge ? C.cyan : C.border}`,background:C.bg,color:C.text,fontSize:12,fontFamily:"'IBM Plex Mono',monospace",boxSizing:"border-box"}} />
                      <div style={{fontSize:9,color:C.textMuted,marginTop:2}}>MAF threshold = 180 - age</div>
                    </div>
                    <div>
                      <label style={{fontSize:10,color:C.textMuted,display:"block",marginBottom:3}}>Peak VO2max (historical)</label>
                      <input type="number" value={userHRConfig.peakHistoricalVO2 || ""} onChange={e => saveHRConfig({...userHRConfig, peakHistoricalVO2: parseFloat(e.target.value) || null})}
                        placeholder="e.g. 54" style={{width:"100%",padding:"6px 8px",borderRadius:5,border:`1px solid ${C.border}`,background:C.bg,color:C.text,fontSize:12,fontFamily:"'IBM Plex Mono',monospace",boxSizing:"border-box"}} />
                      <div style={{fontSize:9,color:C.textMuted,marginTop:2}}>Your best-ever VO2max (Garmin). Used as race day projection ceiling.</div>
                    </div>
                  </div>
                )}

                {/* Zone rows */}
                <div style={{display:"flex",flexDirection:"column",gap:0}}>
                  {hrData.zones.map((z, i) => {
                    const widthPct = Math.max(3, ((z.maxBPM - (z.minBPM||0)) / hrData.maxHR) * 100);
                    const offsetPct = ((z.minBPM||0) / hrData.maxHR) * 100;
                    return (
                      <div key={z.zone} style={{
                        display:"grid",gridTemplateColumns:"28px 120px 1fr 55px 100px",gap:8,alignItems:"center",
                        padding:"10px 0",borderBottom: i < hrData.zones.length - 1 ? "1px solid " + C.border : "none",
                      }}>
                        <div style={{fontSize:13,fontWeight:800,color:z.color,fontFamily:"'Space Grotesk',sans-serif"}}>Z{z.zone}</div>
                        <div>
                          <div style={{fontSize:12,fontWeight:600,color:C.text}}>{z.name}</div>
                          <div style={{fontSize:10,color:C.textMuted}}>{z.purpose}</div>
                        </div>
                        <div style={{position:"relative",height:22,background:C.bg3,borderRadius:4,overflow:"hidden"}}>
                          <div style={{
                            position:"absolute",left:offsetPct+"%",width:widthPct+"%",height:"100%",
                            background:z.color+"40",borderRadius:4,border:"1px solid "+z.color,
                          }}/>
                        </div>
                        <div style={{fontSize:10,color:C.textMuted,textAlign:"center",fontFamily:"'IBM Plex Mono',monospace"}}>{z.pctRange || ""}</div>
                        <div style={{textAlign:"right"}}>
                          <span style={{fontSize:14,fontWeight:700,color:z.color,fontFamily:"'IBM Plex Mono',monospace"}}>{z.minBPM || "<" + z.maxBPM}</span>
                          {z.minBPM > 0 && <span style={{fontSize:11,color:C.textMuted}}> - </span>}
                          {z.minBPM > 0 && <span style={{fontSize:14,fontWeight:700,color:z.color,fontFamily:"'IBM Plex Mono',monospace"}}>{z.maxBPM}</span>}
                          <span style={{fontSize:10,color:C.textMuted,marginLeft:3}}>bpm</span>
                        </div>
                      </div>
                    );
                  })}
                </div>
                <div style={{marginTop:10,fontSize:10,color:C.textMuted,display:"flex",justifyContent:"space-between"}}>
                  <span>Resting HR: {hrData.restingHR} bpm (estimated from activities)</span>
                  <span>Zones recalculate as new workout data arrives</span>
                </div>
              </Card>
            );
          })()}

          {/* Weekly Snapshot */}
          <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(150px, 1fr))", gap: 16 }}>
            <Card>
              <Stat label="This Week" value={weeklyMileage.thisWeekMi.toFixed(1)} unit="mi" color={C.cyan} size={24} />
              <div style={{ marginTop: 6, fontSize: 11, color: weeklyMileage.thisWeekMi >= weeklyMileage.lastWeekMi ? C.green : C.red }}>
                {weeklyMileage.thisWeekMi >= weeklyMileage.lastWeekMi ? "▲" : "▼"} {Math.abs(weeklyMileage.thisWeekMi - weeklyMileage.lastWeekMi).toFixed(1)} mi vs last week
              </div>
            </Card>
            <Card>
              <Stat label="This Week TRIMP" value={thisWeekMetrics.totalTRIMP} unit="" color={C.amber} size={24} />
              <div style={{ marginTop: 6, fontSize: 11, color: C.textMuted }}>
                Last week: {lastWeekMetrics.totalTRIMP}
              </div>
            </Card>
            <Card>
              <Stat label="Cross-Training" value={weeklyMileage.thisWeekXT.toFixed(1)} unit="hrs" color={C.green} size={24} />
              <div style={{ marginTop: 6, fontSize: 11, color: C.textMuted }}>this week</div>
            </Card>
            <Card>
              <Stat label="Month Volume" value={monthlyVolume.thisMi.toFixed(1)} unit="mi" color={C.purple} size={24} />
              <div style={{ marginTop: 6, fontSize: 11, color: C.textMuted }}>
                {monthlyVolume.lastYearMi > 0 ? `vs ${monthlyVolume.lastYearMi.toFixed(1)} mi last year` : "no data last year"}
              </div>
            </Card>
          </div>

          {/* Recovery & Training Effect for yesterday's activities */}
          {yesterdayActivities.length > 0 && (
            <Card style={{borderColor:C.green+"40"}}>
              <div style={{fontSize:13,fontWeight:600,color:C.textDim,marginBottom:14}}>Recovery & Training Effect</div>
              <div style={{display:"flex",flexDirection:"column",gap:12}}>
                {yesterdayActivities.map((act, i) => {
                  const effect = classifyTrainingEffect(act, summaryMaxHR);
                  const recovery = estimateRecoveryHours(act, activities, athleteProfile);
                  const disp = actDisplay(act.type);
                  return (
                    <div key={i} style={{display:"grid",gridTemplateColumns:"auto 1fr auto",gap:16,alignItems:"center",
                      padding:"12px 16px",background:C.bg2,borderRadius:8}}>
                      <span style={{fontSize:18}}>{disp.emoji}</span>
                      <div>
                        <div style={{fontSize:13,fontWeight:600,color:C.text}}>{act.name || disp.label}</div>
                        <div style={{display:"flex",gap:12,marginTop:6}}>
                          <div>
                            <div style={{fontSize:10,color:C.textMuted}}>Aerobic</div>
                            <div style={{display:"flex",gap:2,marginTop:2}}>
                              {[1,2,3,4,5].map(n => (
                                <div key={n} style={{width:12,height:6,borderRadius:2,
                                  background: n <= Math.round(effect.aerobic) ? C.green : C.bg3}} />
                              ))}
                            </div>
                            <div style={{fontSize:10,color:C.green,marginTop:2}}>{effect.aerobic.toFixed(1)}</div>
                          </div>
                          <div>
                            <div style={{fontSize:10,color:C.textMuted}}>Anaerobic</div>
                            <div style={{display:"flex",gap:2,marginTop:2}}>
                              {[1,2,3,4,5].map(n => (
                                <div key={n} style={{width:12,height:6,borderRadius:2,
                                  background: n <= Math.round(effect.anaerobic) ? C.red : C.bg3}} />
                              ))}
                            </div>
                            <div style={{fontSize:10,color:C.red,marginTop:2}}>{effect.anaerobic.toFixed(1)}</div>
                          </div>
                          <Badge label={effect.label} color={
                            effect.label === "Highly Impactful" ? C.red :
                            effect.label === "Impactful" ? C.amber :
                            effect.label === "Maintaining" ? C.green : C.textMuted
                          } />
                        </div>
                      </div>
                      <div style={{textAlign:"center"}}>
                        <div style={{fontSize:20,fontWeight:800,color: recovery && recovery <= 12 ? C.green : recovery <= 24 ? C.amber : C.red,
                          fontFamily:"'Space Grotesk',sans-serif"}}>{recovery || "--"}</div>
                        <div style={{fontSize:10,color:C.textMuted}}>hrs recovery</div>
                      </div>
                    </div>
                  );
                })}
              </div>
            </Card>
          )}

          {/* Training Distribution mini-summary */}
          {(() => {
            const dist = calcTrainingDistribution(activities, 30, hrZoneConfig);
            if (!dist) return null;
            return (
              <Card>
                <div style={{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:12}}>
                  <div style={{fontSize:13,fontWeight:600,color:C.textDim}}>Training Distribution (30d)</div>
                  <Badge label={dist.model} color={
                    dist.model==="Polarized" ? C.green :
                    dist.model==="Pyramidal" ? C.cyan :
                    dist.model==="Threshold" ? C.amber : C.textDim
                  } />
                </div>
                {/* Stacked bar visualization */}
                <div style={{display:"flex",height:28,borderRadius:6,overflow:"hidden",marginBottom:8}}>
                  <div style={{width:`${dist.lowIntensity}%`,background:C.green,display:"flex",alignItems:"center",justifyContent:"center",
                    fontSize:10,fontWeight:700,color:"#000"}}>{dist.lowIntensity}%</div>
                  <div style={{width:`${dist.midIntensity}%`,background:C.amber,display:"flex",alignItems:"center",justifyContent:"center",
                    fontSize:10,fontWeight:700,color:"#000"}}>{dist.midIntensity > 5 ? `${dist.midIntensity}%` : ""}</div>
                  <div style={{width:`${dist.highIntensity}%`,background:C.red,display:"flex",alignItems:"center",justifyContent:"center",
                    fontSize:10,fontWeight:700,color:"#000"}}>{dist.highIntensity > 5 ? `${dist.highIntensity}%` : ""}</div>
                </div>
                <div style={{display:"flex",gap:16,fontSize:11,color:C.textMuted}}>
                  <span><span style={{color:C.green}}>●</span> Low {dist.lowIntensity}%</span>
                  <span><span style={{color:C.amber}}>●</span> Mid {dist.midIntensity}%</span>
                  <span><span style={{color:C.red}}>●</span> High {dist.highIntensity}%</span>
                  <span style={{marginLeft:"auto"}}>{dist.totalHours} hrs total</span>
                </div>
              </Card>
            );
          })()}

          {/* VO2max Trend Chart */}
          {vo2Trend.length > 3 && (
            <Card>
              <div style={{ fontSize: 13, fontWeight: 600, color: C.textDim, marginBottom: 16 }}>Garmin VO₂max Trend</div>
              <ResponsiveContainer width="100%" height={200}>
                <LineChart data={vo2Trend}>
                  <CartesianGrid strokeDasharray="3 3" stroke={C.border} vertical={false} />
                  <XAxis dataKey="date" tick={{ fill: C.textMuted, fontSize: 10 }} axisLine={false} tickLine={false} interval="preserveStartEnd" />
                  <YAxis domain={["dataMin - 1", "dataMax + 1"]} tick={{ fill: C.textMuted, fontSize: 11 }} axisLine={false} tickLine={false} />
                  <Tooltip content={<ChartTooltip formatter={(v) => v.toFixed(1)} />} />
                  <Line type="monotone" dataKey="vo2max" stroke={C.cyan} strokeWidth={2} dot={{ r: 3, fill: C.cyan }} name="VO2max" />
                  {effectiveVO2 && <ReferenceLine y={effectiveVO2} stroke={C.green} strokeDasharray="5 5" label={{ value: `Eff: ${effectiveVO2}`, fill: C.green, fontSize: 11, position: "right" }} />}
                </LineChart>
              </ResponsiveContainer>
            </Card>
          )}
        </div>
      );
    }

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