// running/src/plan.jsx — slice 14 of the module split, the biggest view.
//
// PLAN: training-plan builder, weekly schedule, generation/Smart-Refresh
// flows, Garmin workout push, calendar export. Extracted verbatim from
// running/index.html along with its three display helpers
// (dayTypeToPaceZone / cleanPace / planText — Plan is their only
// consumer) and GOOGLE_CLIENT_ID (calendar export, used nowhere else).
// Pace/zone/load math comes from the UMD libs; the two shared
// calculators ride the __appHelpers bridge; db/functions resolve from
// the page-level firebase script.

const { C, Card, Stat, Badge, Btn, TabBar, EmptyState, ChartTooltip, fmt } = window.SharedUI;
const useData = () => (window.__appUseData ? window.__appUseData() : null);
const calcACRatio = (...a) => window.__appHelpers.calcACRatio(...a);
const calcMarathonShape = (...a) => window.__appHelpers.calcMarathonShape(...a);
const planGoalMarathonPaceSec = (...a) => window.__appHelpers.planGoalMarathonPaceSec(...a);
const { isRun } = window.ActivityTypes;
const { getHRZones } = window.HRZones;
const { calcEffectiveVO2max, calcTrainingPaces, bestRecentQualityEffort, isTrainingRun } = window.RaceMath;
const { calcExecutionScore } = ScoringLib;
const { useState, useEffect, useMemo, useRef } = React;
const {
  Bar, ComposedChart, Line, Cell,
  XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine,
} = Recharts;

const GOOGLE_CLIENT_ID = "649508095422-ghege2ispvvs8nua9f2eld3pqqdeifqi.apps.googleusercontent.com";

    // Map a plan-day workout type to the training-paces zone it should
    // sit in. Returns null for rest days or types we don't pace (e.g.
    // strength, cross-training).
    function dayTypeToPaceZone(type) {
      const t = (type || "").toLowerCase().trim();
      if (!t || t === "rest" || t === "off" || t === "strength" || t === "cross") return null;
      if (/recover/.test(t)) return "Recovery";
      if (/long/.test(t)) return "Long";
      if (/marathon|race|\bmp\b/.test(t)) return "Marathon";
      if (/tempo|threshold|\blt\b|\bt\b\s*pace/.test(t)) return "Threshold";
      if (/interval|vo2|track|repeat|fartlek|\bi\b\s*pace/.test(t)) return "Interval";
      if (/strid|sprint|repetition|\br\b\s*pace/.test(t)) return "Strides";
      if (/easy/.test(t)) return "Easy";
      return null;
    }

    // Render-safety for pace strings. The chip appends "/mi" itself, so
    // a value already carrying a unit/range ("10:30/mi", "10:30-11:00")
    // would show "10:30/mi/mi". Plans written after the server-side
    // normalize are bare M:SS; this keeps weeks generated BEFORE that
    // fix rendering correctly without forcing a re-refresh.
    function cleanPace(p) {
      if (p == null) return p;
      // AI-generated plans occasionally emit a structured paces object
      // here instead of a "M:SS" string — never let one reach JSX.
      if (typeof p === "object") return null;
      const m = String(p).match(/(\d{1,2}):([0-5]\d)/);
      return m ? `${parseInt(m[1], 10)}:${m[2]}` : p;
    }

    // Coerce an AI-generated plan field to safe display text. Plan docs
    // are model-authored and stored in Firestore, so a field typed as a
    // string (description, notes, …) sometimes arrives as an object or
    // array — rendering that straight into JSX throws React error #31
    // and takes down the whole Training page. Flatten it to a readable
    // line instead of crashing.
    function planText(v) {
      if (v == null) return "";
      if (typeof v === "string") return v;
      if (typeof v === "number" || typeof v === "boolean") return String(v);
      if (Array.isArray(v)) return v.map(planText).filter(Boolean).join(" · ");
      if (typeof v === "object") {
        return Object.entries(v)
          .filter(([, val]) => val != null && val !== "")
          .map(([k, val]) => `${k.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/^./, c => c.toUpperCase())}: ${planText(val)}`)
          .join(" · ");
      }
      return String(v);
    }

    function Plan({ userId, activities, isOwner, hrZoneConfig }) {
      const [plans, setPlans] = useState([]);
      const [activePlan, setActivePlan] = useState(null);
      const [races, setRaces] = useState([]);
      const [generatingWeek, setGeneratingWeek] = useState(null); // week number being generated, or "new"
      // "Refresh Plan" progress: { current: N, total: M, skipped: K } while a
      // bulk refresh is running, null when idle.
      const [refreshingPlan, setRefreshingPlan] = useState(null);
      const [generatingDay, setGeneratingDay] = useState(null);   // "weekIdx-dayIdx"
      // Plan Trajectory Review: loading-state for the on-demand button.
      // The review payload itself lives on activePlan.review (cached on the
      // plan doc), so it survives reloads without refetching.
      const [reviewingPlan, setReviewingPlan] = useState(false);
      const [reviewExpanded, setReviewExpanded] = useState(false);
      // Smart Plan Refresh: single deterministic backend pass that bumps
      // peak volume + shortens the taper to land race-day TSB in band.
      const [smartRefreshing, setSmartRefreshing] = useState(false);
      const [refreshMenuWeek, setRefreshMenuWeek] = useState(null); // wi index of open refresh dropdown, or null
      const [error, setError] = useState(null);
      const [success, setSuccess] = useState(null);
      const [selectedRace, setSelectedRace] = useState(null);
      // Week-level expand/collapse — Set-based explicit overrides so
      // the current week can be collapsed. `expandedWeeks` and
      // `collapsedWeeks` are explicit user intents; the default
      // (current week expanded, others collapsed) only applies when
      // the runner hasn't said otherwise.
      const [expandedWeeks, setExpandedWeeks] = useState(() => new Set());
      const [collapsedWeeks, setCollapsedWeeks] = useState(() => new Set());
      const isWeekExpanded = (wi, isCurrent) => {
        if (expandedWeeks.has(wi)) return true;
        if (collapsedWeeks.has(wi)) return false;
        return isCurrent;
      };
      const toggleWeekExpand = (wi, isCurrent) => {
        const currentlyExpanded = isWeekExpanded(wi, isCurrent);
        if (currentlyExpanded) {
          setCollapsedWeeks(s => new Set([...s, wi]));
          setExpandedWeeks(s => { const n = new Set(s); n.delete(wi); return n; });
        } else {
          setExpandedWeeks(s => new Set([...s, wi]));
          setCollapsedWeeks(s => { const n = new Set(s); n.delete(wi); return n; });
        }
      };
      const expandAllWeeks = (numWeeks) => {
        setExpandedWeeks(new Set(Array.from({ length: numWeeks }, (_, i) => i)));
        setCollapsedWeeks(new Set());
      };
      const collapseAllWeeks = (numWeeks) => {
        setCollapsedWeeks(new Set(Array.from({ length: numWeeks }, (_, i) => i)));
        setExpandedWeeks(new Set());
      };
      const [pushingWorkout, setPushingWorkout] = useState(null);
      // Weeks currently being pushed to Garmin (by weekIdx). Shown as a
      // progress indicator on the week header's ⌚ button.
      const [pushingWeek, setPushingWeek] = useState(null);
      const [copiedWeek, setCopiedWeek] = useState(null);

      // Reference training paces used to flag plan days whose generated
      // targetPace no longer matches — see evaluatePaceFreshness above.
      //
      // This MUST be anchored to the same marathon pace the plan is
      // generated against (plan goal MP), exactly like the main page's
      // trainingPaces (calcTrainingPaces(effectiveVO2, runs, goalMpSec)).
      // Previously this passed no goalMpSec, so the badge built zones off
      // a live recency-weighted VO2max while the plan's paces were built
      // off goal MP — two different fitness models that drift apart, so a
      // freshly-refreshed week was flagged STALE immediately. With the
      // goal-MP anchor a fresh refresh is fresh by construction, and
      // STALE only fires on a genuine deviation from the plan's own
      // pace model. Falls back to the VO2max anchor when the plan has no
      // usable marathon goal time.
      const planRuns = useMemo(() => activities.filter(a => isRun(a)), [activities]);
      const planVO2 = useMemo(() => {
        const calc = calcEffectiveVO2max(planRuns);
        const best = bestRecentQualityEffort(planRuns);
        if (best && (!calc || best.vo2 > calc + 1.5)) return best.vo2;
        return calc;
      }, [planRuns]);
      // Live, fitness-tracking pace model for the plan view. The model
      // frozen into the plan at generation time goes stale as fitness
      // improves; future days instead show paces re-derived every render
      // — the faster of the goal anchor and current fitness, so the plan
      // keeps pace with real gains but never prescribes slower than goal.
      const livePaces = useMemo(() => {
        const goalMp = planGoalMarathonPaceSec(activePlan);
        const goalModel = Number.isFinite(goalMp) ? calcTrainingPaces(null, [], goalMp) : null;
        const fitModel = calcTrainingPaces(planVO2, planRuns);
        if (goalModel && fitModel) return fitModel.mp < goalModel.mp ? fitModel : goalModel;
        return fitModel || goalModel || null;
      }, [activePlan, planVO2, planRuns]);
      // Pace shown for a plan day: the plan's prescribed targetPace wins.
      // Plan descriptions often note an explicit "current effort" pace
      // (e.g. "~7:30-8:00/mi current effort") that reflects Claude's
      // intent; silently overriding with the live VO2max model produces
      // a badge that contradicts the prose the athlete is reading and
      // grades them against a pace they were never told to hit. Only
      // re-derive from the live model when the plan day has no
      // explicit pace.
      const planLivePace = (day) => {
        const explicit = cleanPace(day.targetPace);
        if (explicit) return explicit;
        const zone = livePaces ? dayTypeToPaceZone(day.type) : null;
        const range = zone ? livePaces.find(p => p.zone === zone) : null;
        return range
          ? fmt.pace(Math.round((range.min + range.max) / 2)).replace(" /mi", "")
          : null;
      };

      // Render a week as plain text for clipboard sharing — useful for
      // pasting back into a coaching review thread, generating
      // workout summaries, or quick triage of regenerated weeks.
      const copyWeekAsText = (week, wi) => {
        const lines = [];
        const wkNum = week.week || wi + 1;
        const days = week.days || [];
        const totalMi = days.filter(d => d.distanceMi).reduce((s, d) => s + (d.distanceMi || 0), 0);
        const cap = (s) => s ? s.charAt(0).toUpperCase() + s.slice(1).replace(/_/g, " ") : "";

        lines.push(`Week ${wkNum} — ${week.theme || "Training"} (${totalMi.toFixed(1)}mi)`);
        if (week.coachNotes) {
          lines.push("");
          lines.push(week.coachNotes);
        }
        const wi_ = week.weekInsights || {};
        if (wi_.weekFocus)  lines.push(`\nFOCUS: ${wi_.weekFocus}`);
        if (wi_.keySession) lines.push(`KEY:   ${wi_.keySession}`);
        if (wi_.athleteCue) lines.push(`CUE:   ${wi_.athleteCue}`);
        if (wi_.comingUp)   lines.push(`NEXT:  ${wi_.comingUp}`);
        lines.push("");

        days.forEach(d => {
          let header = `${d.day} — ${cap(d.type)}`;
          if (d.distanceMi)    header += ` ${d.distanceMi}mi`;
          if (d.targetPace)    header += ` @ ${d.targetPace}`;
          if (d.hrZone)        header += ` · Z${d.hrZone}`;
          if (d.targetHR)      header += ` (${d.targetHR.low}-${d.targetHR.high} bpm)`;
          if (d.activeVariant === "track") header += " [TRACK]";
          else if (d.indoor)               header += " [INDOOR]";
          lines.push(header);
          if (d.description) lines.push("  " + planText(d.description).replace(/\s+/g, " ").trim());
          if (d.peloton) lines.push(`  Peloton: ${d.peloton.resistance || "?"}, ${d.peloton.cadence || "?"}${d.peloton.rideType ? ", " + d.peloton.rideType : ""}`);
          if (Array.isArray(d.steps) && d.steps.length > 0) {
            lines.push("  STEPS:");
            const renderStep = (s, indent) => {
              if (s.type === "repeat" && Array.isArray(s.children)) {
                lines.push(`${indent}${s.repeat}× repeat:`);
                s.children.forEach(c => renderStep(c, indent + "  "));
              } else {
                let line = `${indent}${cap(s.type) || "Step"}`;
                if (s.distance) line += ` ${s.distance}mi`;
                else if (s.duration) line += ` ${Math.round(s.duration / 60)}min`;
                if (s.pace)     line += ` @ ${s.pace}`;
                if (s.targetHR) line += ` (${s.targetHR.low}-${s.targetHR.high} bpm)`;
                if (s.description) line += ` — ${s.description}`;
                lines.push(line);
              }
            };
            d.steps.forEach(s => renderStep(s, "    "));
          }
          if (d.nutrition) lines.push(`  Nutrition: ${d.nutrition}`);
          lines.push("");
        });

        const text = lines.join("\n").replace(/\n{3,}/g, "\n\n").trim();
        navigator.clipboard.writeText(text).then(() => {
          setCopiedWeek(wi);
          setTimeout(() => setCopiedWeek(null), 2000);
        }).catch((err) => {
          console.error("[chris.run] copy failed:", err);
          alert("Copy failed — see console.");
        });
      };
      const [indoor, setIndoor] = useState(false);
      const [weather, setWeather] = useState(null);
      const [weatherLoading, setWeatherLoading] = useState(false);
      const [expandedDay, setExpandedDay] = useState(() => new Set());
      const [attributionWeek, setAttributionWeek] = useState(null);
      const toggleExpandedDay = (key) => {
        setExpandedDay(prev => {
          const next = new Set(prev);
          if (next.has(key)) next.delete(key); else next.add(key);
          return next;
        });
      };
      const [editingDay, setEditingDay] = useState(null); // { wi, di, data }
      const [loggingDay, setLoggingDay] = useState(null); // { wi, di } — manual activity log form
      const [linkingDay, setLinkingDay] = useState(null); // "wi-di" — Strava activity picker
      const [logForm, setLogForm] = useState({ distanceMi: "", durationMin: "", avgHR: "", notes: "" });
      const [hrZoneType, setHrZoneType] = useState("percentage_max");
      const [planTerrain, setPlanTerrain] = useState("mixed");
      const [includeXT, setIncludeXT] = useState(true);
      const [includeNutrition, setIncludeNutrition] = useState(true);
      const [intensityBias, setIntensityBias] = useState("balanced");
      // Live HR zones — from athlete profile (single source of truth via DataProvider)
      const { athleteProfile, healthEntries, consumePlanRefreshRequest, trainingSnapshot } = useData();
      const hrZones = athleteProfile || getHRZones(hrZoneConfig, activities || []);
      const [runDays, setRunDays] = useState(["Mon","Tue","Wed","Thu","Fri","Sat","Sun"]);

      // Load saved plan settings + HR zones + HR method from single source of truth
      useEffect(() => {
        if (!userId) return;
        db.collection("users").doc(userId).collection("settings").doc("planSettings")
          .get().then(doc => {
            if (doc.exists) {
              const s = doc.data();
              if (s.terrain) setPlanTerrain(s.terrain);
              if (s.includeXT !== undefined) setIncludeXT(s.includeXT);
              if (s.includeNutrition !== undefined) setIncludeNutrition(s.includeNutrition);
              if (s.intensityBias) setIntensityBias(s.intensityBias);
              if (s.runDays) setRunDays(s.runDays);
            }
          });
        // HR zones now computed live from activities — no Firestore load needed here
      }, [userId]);

      // Save plan settings when they change
      const savePlanSettings = (updates) => {
        if (!userId) return;
        db.collection("users").doc(userId).collection("settings").doc("planSettings")
          .set(updates, { merge: true });
      };

      // Fetch weather on mount (outdoor mode only)
      useEffect(() => {
        if (indoor || weather) return;
        setWeatherLoading(true);
        fetch("https://api.open-meteo.com/v1/forecast?latitude=40.71&longitude=-74.01&current=temperature_2m,relative_humidity_2m,wind_speed_10m,weather_code&temperature_unit=fahrenheit&wind_speed_unit=mph")
          .then(r => r.json()).then(d => {
            if (d.current) {
              const codes = { 0:"Clear", 1:"Mostly clear", 2:"Partly cloudy", 3:"Overcast", 45:"Fog", 51:"Light drizzle", 61:"Light rain", 63:"Rain", 65:"Heavy rain", 71:"Snow", 80:"Showers", 95:"Thunderstorm" };
              setWeather({
                tempF: Math.round(d.current.temperature_2m),
                humidity: d.current.relative_humidity_2m,
                wind: `${Math.round(d.current.wind_speed_10m)} mph`,
                conditions: codes[d.current.weather_code] || "Unknown",
              });
            }
          }).catch(() => {}).finally(() => setWeatherLoading(false));
      }, [indoor]);

      // Load plans and races
      useEffect(() => {
        if (!userId) return;
        const unsubPlans = db.collection("users").doc(userId).collection("trainingPlans")
          .orderBy("createdAt","desc").onSnapshot(snap => {
            const p = snap.docs.map(d => ({ id:d.id, ...d.data() }));
            setPlans(p);
            // Keep activePlan in sync with Firestore writes. Previously
            // this only set activePlan the FIRST time (`!activePlan`
            // guard), so Smart Refresh / edit / complete writes landed
            // in the DB but the UI kept showing stale data until a hard
            // page reload. Now we match by ID on every snapshot and
            // replace the in-memory plan object — so the week totals
            // and day cards reflect lived state immediately.
            setActivePlan(prev => {
              if (prev) {
                const refreshed = p.find(pl => pl.id === prev.id);
                return refreshed || prev;
              }
              return p.find(pl => pl.status === "active") || null;
            });
          });
        const unsubRaces = db.collection("users").doc(userId).collection("races")
          .orderBy("date","asc").onSnapshot(snap => {
            setRaces(snap.docs.map(d => ({ id:d.id, ...d.data() })));
          });
        return () => { unsubPlans(); unsubRaces(); };
      }, [userId]);

      // Calculate current week number
      // Week boundaries anchor to plan.createdAt — Strava activities and
      // plan-days bucket against the same date ranges this way. PR #164
      // tried Mon-Sun anchoring but that shifted historical week ranges
      // and re-bucketed actuals into wrong weeks. Reverted.
      // Mon-Sun aligned anchor for plan weeks (UK/EU convention, no
      // American Sun-start). Week 1 starts the Monday on or after the
      // plan's createdAt — never rolls backward, so a plan generated
      // mid-week doesn't suddenly bucket pre-creation activity into
      // Week 1 (the regression that prompted PR #168's revert). For a
      // plan created Sun Mar 29, Week 1 starts Mon Mar 30 and Week 31
      // ends Sun Nov 1 — race day lands as the final day of the plan
      // instead of one day past the end.
      // Delegate to the shared helper so plan-week date arithmetic
      // stays consistent across every consumer (Plan Compliance,
      // Weekly Mileage chart, week cards, Race Readiness, …).
      const planWeek1Start = (createdAtRaw) => {
        return window.PlanMath.planWeek1Start({ createdAt: createdAtRaw });
      };

      const getCurrentWeek = (plan) => window.PlanMath.currentPlanWeek(plan);

      // Match actual activities to planned workouts
      const getCompletionForWeek = (plan, weekNum) => {
        const w1 = planWeek1Start(plan?.createdAt);
        if (!w1 || !activities.length) return {};
        const weekStart = new Date(w1.getTime() + (weekNum - 1) * 7 * 86400000);
        const weekEnd = new Date(weekStart.getTime() + 7 * 86400000);
        // Include all activity types so cross-train days can match rides
        // Deduplicate by activity ID to prevent double-counting from multiple sync sources
        const seen = new Set();
        const weekActivities = activities.filter(a => {
          const d = a.start_date?.seconds ? new Date(a.start_date.seconds * 1000) : null;
          if (!d || d < weekStart || d >= weekEnd) return false;
          if (a.id && seen.has(a.id)) return false;
          if (a.id) seen.add(a.id);
          return true;
        });
        // totalMi counts running miles only (typeOverride-aware so a
        // re-tagged ride is excluded).
        const totalMi = weekActivities.filter(a => isRun(a))
          .reduce((s,a) => s + a.distance / 1609.34, 0);
        return { activities: weekActivities, totalMi };
      };

      // Mileage credited to a planned week, following linkedActivityId when a run
      // is moved to another calendar date. Runs linked to THIS week are always
      // counted even if their actual date falls outside the week window. Runs
      // that happened this week but are linked to a DIFFERENT week are excluded.
      // Single source of truth — see running/lib/planMath.js. Handles
      // all the linkedActivityId edge cases (runs credited to other
      // weeks excluded; runs linked to THIS week kept even when their
      // date fell outside the window) and dedupes activities by id.
      const getAdjustedMiForWeek = (plan, weekNum) => {
        return window.PlanMath.actualMiForWeek(plan, weekNum - 1, activities);
      };

      // Per-activity attribution for a given week — mirrors the
      // actualMiForWeek decision tree (date-range vs linked-elsewhere vs
      // linked-here-out-of-window) so the UI can explain WHY each run
      // is or isn't counted. Surfaced via the ⓘ tooltip on each week
      // card so mis-attributions (e.g. an activity linked to the wrong
      // week pulling 7mi into the wrong bucket) are debuggable from
      // the plan view instead of needing a Firestore trace.
      const getWeekAttribution = (plan, weekNum, acts) => {
        const weekIdx = weekNum - 1;
        if (!plan || !plan.weeks || !plan.weeks[weekIdx]) return { entries: [], totalMi: 0 };
        const range = window.PlanMath.weekDateRange(plan, weekIdx);
        if (!range) return { entries: [], totalMi: 0 };
        const { start, end } = range;
        const idKey = (v) => (v == null ? null : String(v));
        const allLinkedIds = new Set();
        const thisWeekLinkedIds = new Set();
        const linkedIdMeta = {};
        (plan.weeks || []).forEach((w, i) => {
          (w.days || []).forEach(d => {
            if (d.linkedActivityId != null) {
              const k = idKey(d.linkedActivityId);
              allLinkedIds.add(k);
              linkedIdMeta[k] = { weekNum: i + 1, dayName: d.day };
              if (i === weekIdx) thisWeekLinkedIds.add(k);
            }
          });
        });
        const toMs = (raw) => {
          if (!raw) return null;
          if (typeof raw.toDate === "function") return raw.toDate().getTime();
          if (typeof raw.seconds === "number") return raw.seconds * 1000;
          if (typeof raw === "number") return raw < 1e12 ? raw * 1000 : raw;
          const t = Date.parse(raw);
          return Number.isFinite(t) ? t : null;
        };
        const seen = new Set();
        const runs = (acts || []).filter(a => {
          // Honor manual reclassification (typeOverride) via the shared
          // classifier so the attribution tooltip matches actualMiForWeek —
          // a ride re-tagged from a Strava "Run" must not show up as a
          // counted run here either.
          if (!isRun(a)) return false;
          const k = idKey(a.id);
          if (k != null) {
            if (seen.has(k)) return false;
            seen.add(k);
          }
          return true;
        });
        const entries = [];
        let totalMeters = 0;
        for (const r of runs) {
          const t = toMs(r.start_date_local || r.start_date);
          if (t == null || t < start.getTime() || t >= end.getTime()) continue;
          const k = idKey(r.id);
          const mi = (r.distance || 0) / 1609.34;
          const dateLabel = fmt.dateShort(r.start_date_local || r.start_date);
          if (k != null && allLinkedIds.has(k) && !thisWeekLinkedIds.has(k)) {
            const meta = linkedIdMeta[k];
            entries.push({ id: k, dateLabel, mi, credited: false, sort: t,
              reason: `Linked to W${meta?.weekNum ?? "?"} ${meta?.dayName || "?"} — credited there instead.` });
          } else {
            totalMeters += r.distance || 0;
            const linkedHere = thisWeekLinkedIds.has(k);
            entries.push({ id: k, dateLabel, mi, credited: true, sort: t,
              reason: linkedHere
                ? `In window · linked to ${linkedIdMeta[k]?.dayName || "?"}.`
                : `In window (date attribution, no day link).` });
          }
        }
        for (const id of thisWeekLinkedIds) {
          const r = runs.find(a => idKey(a.id) === id);
          if (!r) continue;
          const t = toMs(r.start_date_local || r.start_date);
          if (t != null && t >= start.getTime() && t < end.getTime()) continue;
          const mi = (r.distance || 0) / 1609.34;
          const dateLabel = t ? fmt.dateShort(r.start_date_local || r.start_date) : "?";
          totalMeters += r.distance || 0;
          entries.push({ id, dateLabel, mi, credited: true, sort: t || 0,
            reason: `Linked to ${linkedIdMeta[id]?.dayName || "?"} — date outside the week window but counted via the link.` });
        }
        entries.sort((a, b) => (a.sort || 0) - (b.sort || 0));
        return { entries, totalMi: totalMeters / 1609.34 };
      };

      // Aggregate all activities on a given day-of-week that match the planned workout type
      const getDayActivities = (weekComp, dayName, dayType, dayOfWeekMap) => {
        const runTypes = ["easy","recovery","tempo","intervals","long","race_pace","race"];
        const isRunDay = runTypes.includes(dayType);
        // Cross-train activity types that match a NON-run plan day.
        const XT_STRAVA = ["Ride","VirtualRide","EBikeRide","Workout","WeightTraining"];
        const matched = (weekComp.activities || []).filter(a => {
          const ad = a.start_date?.seconds ? new Date(a.start_date.seconds * 1000) : null;
          if (!ad || ad.getDay() !== dayOfWeekMap[dayName]) return false;
          // Match by the canonical, typeOverride-aware classifier — NOT the
          // raw Strava type. A family ride that Strava logged as a "Run" and
          // the athlete re-tagged as "Ride" must NOT match a run day: doing
          // so auto-completes the day and writes the ride's distance into the
          // day's actualMi, which then inflates the weekly RUNNING total.
          if (isRunDay) return isRun(a);
          return !isRun(a) && XT_STRAVA.includes(window.ActivityTypes.effectiveType(a));
        });
        // Deduplicate by activity ID (same activity can appear from multiple sync sources)
        const seen = new Set();
        return matched.filter(a => {
          if (!a.id) return true;
          if (seen.has(a.id)) return false;
          seen.add(a.id);
          return true;
        });
      };

      const aggregateDayActivities = (acts) => {
        if (!acts || acts.length === 0) return null;
        const totalDist = acts.reduce((s, a) => s + (a.distance || 0), 0);
        const hrActs = acts.filter(a => a.average_heartrate);
        const avgHR = hrActs.length > 0
          ? Math.round(hrActs.reduce((s, a) => s + a.average_heartrate, 0) / hrActs.length)
          : null;
        const maxHR = Math.max(...acts.map(a => a.max_heartrate || 0)) || null;
        const totalTime = acts.reduce((s, a) => s + (a.moving_time || 0), 0);
        // Pass through Garmin dynamics from the primary activity
        const dynAct = acts.find(a => a.dynamics && (a.dynamics.groundContactTime || a.dynamics.cadenceSpm || a.dynamics.power));
        return {
          distance: totalDist,
          average_heartrate: avgHR,
          max_heartrate: maxHR,
          moving_time: totalTime,
          name: acts[0].name,
          start_date: acts[0].start_date,
          total_elevation_gain: acts.reduce((s, a) => s + (a.total_elevation_gain || 0), 0),
          dynamics: dynAct?.dynamics || null,
          _count: acts.length,
        };
      };

      // Auto-mark planned days as completed when matching Strava activity exists.
      // Also resyncs each touched week's targetMiles from the Strava-actual
      // sum + remaining-planned sum, so the chart's green bars + weekly
      // headers come back after a refresh wiped them.
      useEffect(() => {
        if (!activePlan?.id || !activePlan?.weeks?.length || !activities.length || !userId) return;
        const currentWeek = getCurrentWeek(activePlan);
        const dayOfWeekMap = { Monday:1, Tuesday:2, Wednesday:3, Thursday:4, Friday:5, Saturday:6, Sunday:0 };
        let needsUpdate = false;
        const garminCleanups = [];
        const RUN_TYPES_AC = ["easy","recovery","tempo","intervals","long","race_pace","race"];
        const updatedWeeks = activePlan.weeks.map((week, wi) => {
          if (wi + 1 > currentWeek) return week; // only check current and past weeks
          const comp = getCompletionForWeek(activePlan, wi + 1);
          if (!comp.activities?.length) return week;
          let weekTouched = false;
          const updatedDays = (week.days || []).map((day, di) => {
            if (day.skipped || day.type === "rest") return day;
            if (!RUN_TYPES_AC.includes(day.type)) return day;
            const dayActs = getDayActivities(comp, day.day, day.type, dayOfWeekMap);
            const agg = aggregateDayActivities(dayActs);
            if (agg) {
              // Backfill: already completed but missing stored grade (pre-fix records)
              if (day.completed && day.completedGrade != null) return day; // already has grade, skip
              needsUpdate = true;
              weekTouched = true;
              if (!day.completed && day.garminWorkoutId) {
                garminCleanups.push({ weekIdx: wi, dayIdx: di, day });
              }
              const exec = typeof calcExecutionScore === "function" ? calcExecutionScore(day, agg) : null;
              return {
                ...day,
                completed: true,
                actualMi: Math.round(agg.distance / 1609.34 * 10) / 10,
                ...(exec ? {
                  completedGrade: exec.grade,
                  completedScore: exec.score,
                  completedGradeDetails: exec.details,
                } : {}),
              };
            }
            return day;
          });
          // Don't overwrite week.targetMiles here. PR #160 used to
          // recompute it as sum of (actualMi for completed + distanceMi
          // for open) — but that destroyed the original planned target
          // for completed weeks. The chart and the weekly card both now
          // compute "planned" directly from sum of distanceMi at render
          // time, so we don't need to keep targetMiles in sync — and
          // actively NOT overwriting it preserves the original prescription
          // even after Strava-derived auto-completion fires.
          return { ...week, days: updatedDays };
        });
        if (needsUpdate) {
          db.collection("users").doc(userId).collection("trainingPlans").doc(activePlan.id)
            .update({ weeks: updatedWeeks, updatedAt: firebase.firestore.FieldValue.serverTimestamp() });
          // Fire-and-forget Garmin cleanup for each newly-completed day.
          // The callable also strips garminWorkoutId from the plan day, so
          // a re-run of this effect won't see the field and won't double-fire.
          garminCleanups.forEach(({ weekIdx, dayIdx, day }) =>
            cleanupGarminForCompletedDay(activePlan.id, weekIdx, dayIdx, day));
        }
      }, [activePlan?.id, activities.length]);

      // Build fitness data from recent activities
      const getFitnessData = () => {
        // Filter to TRAINING runs only — drops family walks / dog-walk-jogs
        // / recovery saunters that get logged as Run type but pace at 14:00+/mi.
        // Without this filter, those leisure activities inflate weeklyMiles,
        // contaminate the VO2max pool, and pollute the LT2 anchor the backend
        // uses to size next week's paces.
        const runs = activities.filter(isTrainingRun);
        const recentRuns = runs.sort((a,b) => (b.start_date?.seconds||0) - (a.start_date?.seconds||0)).slice(0,10);
        // Calculate actual current week mileage (Mon-Sun)
        const now = new Date();
        const monday = new Date(now); monday.setDate(now.getDate() - ((now.getDay() + 6) % 7)); monday.setHours(0,0,0,0);
        const thisWeekRuns = runs.filter(r => {
          const d = r.start_date?.seconds ? new Date(r.start_date.seconds * 1000) : new Date(r.start_date);
          return d >= monday;
        });
        const thisWeekMiles = thisWeekRuns.reduce((s,r) => s + r.distance/1609.34, 0);
        // Last full week
        const lastMonday = new Date(monday); lastMonday.setDate(lastMonday.getDate() - 7);
        const lastWeekRuns = runs.filter(r => {
          const d = r.start_date?.seconds ? new Date(r.start_date.seconds * 1000) : new Date(r.start_date);
          return d >= lastMonday && d < monday;
        });
        const lastWeekMiles = lastWeekRuns.reduce((s,r) => s + r.distance/1609.34, 0);
        // Use last full week if available, otherwise current week
        const weeklyMiles = lastWeekMiles > 0 ? lastWeekMiles : thisWeekMiles;
        // Empirical LT2 pace (sec/mi) from the server-built racePrep
        // snapshot. When present and faster than the VO2max-derived MP,
        // the backend uses it as the current-fitness anchor in
        // calcTrainingPaces, so every zone band (easy, threshold,
        // interval) tracks recent threshold execution rather than
        // lagging the smoothed VO2 average.
        const lt2PaceSec = trainingSnapshot?.racePrep?.currentLT?.lt2?.pace ?? null;
        return {
          vo2max: typeof calcEffectiveVO2max === "function" ? calcEffectiveVO2max(runs) : null,
          lt2PaceSec: Number.isFinite(lt2PaceSec) && lt2PaceSec > 0 ? Math.round(lt2PaceSec) : null,
          weeklyMiles: weeklyMiles.toFixed(1),
          marathonShape: typeof calcMarathonShape === "function" ? calcMarathonShape(runs) : null,
          acRatio: typeof calcACRatio === "function" ? calcACRatio(activities, athleteProfile)?.ratio : null,
          recentRuns: recentRuns.slice(0,5).map(r => ({
            name: r.name,
            distanceMi: (r.distance/1609.34).toFixed(1),
            pace: fmt.pace(r.moving_time / (r.distance/1609.34)),
            avgHR: r.average_heartrate,
          })),
        };
      };

      // Generate a single week — used by "Generate Plan" (week 1), "Add a Week", and "Refresh Week"
      const generateWeek = async ({ planId, weekNum, replace = false, forceComplete = false } = {}) => {
        const race = planId ? activePlan : selectedRace;
        if (!race) { setError("Select a race first"); return; }

        const targetWeekNum = planId
          ? (replace ? weekNum : (activePlan?.weeks?.length || 0) + 1)
          : 1;

        // When replacing, find locked (completed/skipped) days to preserve
        // forceComplete skips this so all 7 days get regenerated from scratch
        let lockedDays = null;
        // Hoisted so the backend payload can pick up the prior week's
        // target + prescription further down — Smart Refresh uses both to
        // pin the weekly total and preserve quality workout types.
        const existingWeek = (replace && planId && activePlan?.weeks)
          ? activePlan.weeks[targetWeekNum - 1]
          : null;
        if (replace && !forceComplete && planId && existingWeek) {
          if (existingWeek?.days) {
            // Lock any day the athlete has already invested in:
            //   - completed / skipped: the workout is done one way or the other.
            //   - manuallyEdited: hand-tuned (e.g. bumping a tempo 6 → 7mi
            //     to insure a jump week) — the edit must survive a Smart
            //     Refresh that otherwise rebuilds from the AI prompt.
            //   - linkedActivityId: a Strava/Garmin activity is attached.
            //     A live link means the day is "answered" — even if
            //     `completed` slipped (an old unlink reset it; or the
            //     activity arrived before the completion checkbox was
            //     toggled), refreshing it would silently rewrite the
            //     prescription out from under the athlete's data.
            const locked = existingWeek.days.filter(d => d.completed || d.skipped || d.manuallyEdited || d.linkedActivityId);
            if (locked.length > 0) lockedDays = locked;
          }
        }

        // When adding a NEW week, check for Strava activities already completed in this week's window
        if (!replace && planId && activePlan && activities.length > 0) {
          const comp = getCompletionForWeek(activePlan, targetWeekNum);
          if (comp.activities?.length > 0) {
            const dayNumToName = { 0: "Sunday", 1: "Monday", 2: "Tuesday", 3: "Wednesday", 4: "Thursday", 5: "Friday", 6: "Saturday" };
            const runActivities = comp.activities.filter(a => a.type === "Run" || a.type === "VirtualRun");
            // Group running activities by day of week
            const dayGroups = {};
            runActivities.forEach(a => {
              const d = a.start_date?.seconds ? new Date(a.start_date.seconds * 1000) : null;
              if (!d) return;
              const dayName = dayNumToName[d.getDay()];
              if (!dayGroups[dayName]) dayGroups[dayName] = 0;
              dayGroups[dayName] += a.distance / 1609.34;
            });
            const activityLockedDays = Object.entries(dayGroups).map(([dayName, mi]) => ({
              day: dayName,
              type: mi >= 8 ? "long" : "easy",
              actualMi: Math.round(mi * 10) / 10,
              distanceMi: Math.round(mi * 10) / 10,
              completed: true,
            }));
            if (activityLockedDays.length > 0) lockedDays = activityLockedDays;
          }
        }

        setGeneratingWeek(planId ? targetWeekNum : "new");
        setError(null); setSuccess(null);
        try {
          // Build historical context from garmin-history if available
          let historicalContext = null;
          try {
            const histResp = await fetch("/running/data/garmin-history.json");
            if (histResp.ok) {
              const hist = await histResp.json();
              const recent12 = hist.weekly.slice(-12);
              const avgMi = recent12.reduce((s,w) => s+w.miles,0)/12;
              const peakMi = Math.max(...hist.weekly.map(w => w.miles));
              const avgGCT = recent12.filter(w=>w.gct).reduce((s,w)=>s+w.gct,0)/(recent12.filter(w=>w.gct).length||1);
              const avgCad = recent12.filter(w=>w.avgCad).reduce((s,w)=>s+w.avgCad,0)/(recent12.filter(w=>w.avgCad).length||1);
              const latestPred = hist.racePredictions.length > 0 ? hist.racePredictions[hist.racePredictions.length-1] : null;
              historicalContext = `${hist.totalRuns} runs over ${hist.dateRange.from} to ${hist.dateRange.to}. Recent 12-week avg: ${avgMi.toFixed(1)} mi/wk. All-time peak week: ${peakMi} mi. Avg GCT: ${Math.round(avgGCT)}ms, Cadence: ${Math.round(avgCad)} spm. ${latestPred ? `Latest marathon prediction: ${Math.floor(latestPred.marathon/3600)}:${String(Math.floor((latestPred.marathon%3600)/60)).padStart(2,"0")}:${String(Math.round(latestPred.marathon%60)).padStart(2,"0")}` : ""}`;
            }
          } catch(e) {}

          // Prior-week miles actually run per Strava — let the backend
          // use lived reality for the ±10% band instead of falling back
          // to stale planned distanceMi on days that were marked
          // complete before actualMi stamping existed (or days not yet
          // marked complete in the current in-progress week).
          const priorWeekStravaMi = planId && targetWeekNum > 1
            ? (Math.round((getAdjustedMiForWeek(activePlan, targetWeekNum - 1) || 0) * 10) / 10)
            : null;

          // ── Adaptive training-MP target ──────────────────────────────
          // Inside the marathon-specific block (≤16 weeks out), feed
          // recent quality executions into effectiveTargetPace to either
          // tighten (consistently A-graded + ahead of pace), relax
          // (D grade or slow execution), or hold the prescribed MP.
          // Outside the block this is a no-op and returns race.targetPace
          // — the base/build phase doesn't get adaptive MP work.
          let effectiveTargetPace = null;
          let effectiveTargetPaceMeta = null;
          try {
            if (window.EffectiveTargetPace && activePlan?.weeks?.length) {
              const raceDateMs = (() => {
                const d = race.raceDate || race.date;
                if (!d) return null;
                const t = typeof d === "string" ? Date.parse(d) : (d?.toDate ? d.toDate().getTime() : null);
                return Number.isFinite(t) ? t : null;
              })();
              const weeksOut = raceDateMs ? Math.max(0, Math.ceil((raceDateMs - Date.now()) / (7 * 86400000))) : null;
              // Walk the plan, pull every completed quality day with a
              // linkedActivityId, and convert to the (planType, target,
              // actual, grade, dateMs) shape effectiveTargetPace expects.
              const recentQualityRuns = [];
              const QUAL = window.EffectiveTargetPace.QUALITY_TYPES;
              (activePlan.weeks || []).forEach(w => {
                (w?.days || []).forEach(d => {
                  if (!d.completed || !d.completedGrade || !d.linkedActivityId) return;
                  const t = String(d.type || "").toLowerCase();
                  if (!QUAL.has(t)) return;
                  const act = (activities || []).find(a => String(a.id) === String(d.linkedActivityId));
                  if (!act || !(act.distance > 0) || !(act.moving_time > 0)) return;
                  const targetSec = window.EffectiveTargetPace.parsePaceMSS(d.targetPace);
                  if (!targetSec) return;
                  const actualSec = act.moving_time / (act.distance / 1609.34);
                  const dateMs = act.start_date_local
                    ? Date.parse(act.start_date_local)
                    : (act.start_date?.toDate ? act.start_date.toDate().getTime()
                       : (act.start_date?.seconds ? act.start_date.seconds * 1000 : null));
                  if (!Number.isFinite(dateMs)) return;
                  recentQualityRuns.push({
                    planType: t,
                    targetPaceSec: targetSec,
                    actualPaceSec: actualSec,
                    grade: d.completedGrade,
                    dateMs,
                  });
                });
              });
              const result = window.EffectiveTargetPace.effectiveTargetPace({
                raceTargetPace: race.targetPace,
                recentQualityRuns,
                weeksOut,
              });
              if (result && result.pace && result.source !== "no-base") {
                effectiveTargetPace = result.pace;
                effectiveTargetPaceMeta = {
                  source: result.source,
                  reason: result.reason,
                  sampleSize: result.sampleSize,
                };
              }
            }
          } catch (e) {
            console.warn("[generateWeek] effectiveTargetPace failed:", e.message);
          }

          // Bump client-side timeout to match the server's 300s budget.
          // Default httpsCallable timeout is 70s — too tight when Opus
          // generates a complex week prompt + the rule pipeline retries.
          // Without this, the client throws functions/deadline-exceeded
          // before the function actually finishes server-side.
          const fn = functions.httpsCallable("generateTrainingPlan", { timeout: 300000 });
          const result = await fn({
            race: {
              name: race.raceName || race.name,
              date: race.raceDate || race.date,
              distance: race.raceDistance || race.distance,
              goalTime: race.goalTime,
              // Athlete-specified marathon pace target — overrides the
              // pace implied by goalTime/distance. Useful when the athlete
              // wants to train at a faster MP than their goal time
              // suggests (build sub-goal-time buffer for distance drift /
              // fade), e.g. goal 3:00:00 (6:52/mi) but train at 6:40 to
              // bank insurance.
              targetPace: race.targetPace,
              // Adaptive override (≤16 weeks out only). When set, the
              // backend uses this as the training MP target instead of
              // race.targetPace; meta is included so Claude knows whether
              // the band moved and why.
              effectiveTargetPace,
              effectiveTargetPaceMeta,
            },
            fitness: getFitnessData(),
            weekNum: targetWeekNum,
            indoor,
            weather: indoor ? null : weather,
            hrZones: hrZones || undefined,
            planSettings: { hrZoneType, terrain: planTerrain, includeXT, includeNutrition, intensityBias, runDays },
            historicalContext,
            ...(planId ? { planId, replace } : {}),
            ...(lockedDays ? { lockedDays } : {}),
            ...(priorWeekStravaMi != null ? { priorWeekStravaMi } : {}),
            // Smart Refresh: pin the weekly total to the prior target
            // and hand Claude the current prescription so unlocked
            // quality types (tempo / threshold / intervals / race_pace)
            // get preserved across the regen instead of being silently
            // swapped to a different quality style.
            ...(replace && existingWeek?.targetMiles
              ? { previousWeekTarget: existingWeek.targetMiles }
              : {}),
            ...(replace && existingWeek?.days
              ? { priorPrescription: existingWeek.days.map(d => ({
                  day: d.day,
                  type: d.type,
                  distanceMi: d.distanceMi,
                })) }
              : {}),
          });
          if (result.data.success) {
            // Merge locked-day pass-through (completed / skipped / linked
            // / manually-edited) and carry athlete notes across the
            // regen for non-locked days, so a free-text comment left
            // for the coach can't be wiped by a Smart Refresh.
            const priorDays = (planId && existingWeek?.days) ? existingWeek.days : [];
            const needsMerge = (lockedDays && lockedDays.length > 0)
              || priorDays.some(d => typeof d.athleteNote === "string" && d.athleteNote.trim());
            if (planId && result.data.week && needsMerge) {
              const genWeek = result.data.week;
              const mergedDays = (genWeek.days || []).map(d => {
                const locked = lockedDays?.find(ld => ld.day === d.day);
                if (locked) return locked;
                const prior = priorDays.find(pd => pd.day === d.day);
                if (prior && typeof prior.athleteNote === "string" && prior.athleteNote.trim()) {
                  return { ...d, athleteNote: prior.athleteNote };
                }
                return d;
              });
              // Save the merged result
              const planRef = db.collection("users").doc(userId).collection("trainingPlans").doc(planId);
              const snap = await planRef.get();
              if (snap.exists) {
                const weeks = [...(snap.data().weeks || [])];
                weeks[targetWeekNum - 1] = { ...genWeek, days: mergedDays };
                await planRef.update({ weeks, updatedAt: firebase.firestore.FieldValue.serverTimestamp() });
              }
            }
            setSuccess(replace ? `Week ${targetWeekNum} refreshed${lockedDays ? ` (${lockedDays.length} completed/skipped days kept)` : ""}!` : planId ? `Week ${targetWeekNum} added!` : "Week 1 created — add more weeks below!");
            if (!planId) setSelectedRace(null);
          }
        } catch (err) {
          // Surface the underlying details — Firebase callable errors come
          // wrapped (err.code, err.details, err.message). The plain message
          // is often something terse like "API error" that hides what
          // actually went wrong server-side; including code + details makes
          // it diagnosable from the toast alone.
          const detail = err?.details ? ` (${typeof err.details === "string" ? err.details : JSON.stringify(err.details)})` : "";
          const code = err?.code ? `[${err.code}] ` : "";
          setError(`Plan generation failed: ${code}${err?.message || "Unknown error"}${detail}`);
          console.error("[generateWeek] failed:", err);
        }
        setGeneratingWeek(null);
      };

      // Smart-refresh every week of the plan in sequence.
      //
      // For each week with at least one non-locked (not completed, not
      // skipped) day, calls generateWeek({ replace: true }) which preserves
      // completed / skipped days and regenerates the rest through the
      // post-PR-49 pipeline (progressive-overload ±10% band, long-run
      // anchor, HR zone normalization, treadmill guidance, variants).
      //
      // Weeks whose days are all completed/skipped are skipped — nothing
      // would change since every slot is locked.
      // On-demand plan trajectory review. The backend caches the result on
      // the plan doc (activePlan.review / .reviewedAt / .reviewStaleAt) so a
      // subsequent page load shows the same review without a re-call. The
      // panel below the plan header reads from those fields directly.
      const reviewPlan = async () => {
        if (!activePlan?.id) return;
        setReviewingPlan(true);
        try {
          const fn = functions.httpsCallable("reviewPlanTrajectory", { timeout: 180000 });
          await fn({ planId: activePlan.id });
          setReviewExpanded(true);
          setSuccess("Plan review updated");
          setTimeout(() => setSuccess(null), 3000);
        } catch (e) {
          console.error("[reviewPlan]", e);
          setError("Plan review failed: " + (e?.message || "unknown"));
          setTimeout(() => setError(null), 6000);
        } finally {
          setReviewingPlan(false);
        }
      };

      // Smart Plan Refresh — ONE backend operation (no AI, no per-week loop)
      // that reshapes every remaining week to land race-day form in the
      // +15..+25 TSB band: +5–8mi/week through the peak, a 2-week taper
      // (down from 3) with intensity preserved, and a recomputed projection.
      // Completed / skipped / manually-edited days are untouched.
      const smartRefreshPlan = async () => {
        if (!activePlan?.id) return;
        const confirmed = confirm(
          "Smart Plan Refresh\n\n" +
          "Reshapes every remaining week in one pass:\n" +
          "• +5–8 mi/week through the peak (to hit a +15–25 race-day TSB)\n" +
          "• 2-week taper instead of 3 (intensity kept)\n" +
          "• Completed, skipped, and hand-edited days are preserved\n\n" +
          "This is instant — no AI, no per-week wait. Proceed?"
        );
        if (!confirmed) return;
        setSmartRefreshing(true);
        setError(null);
        try {
          const fn = functions.httpsCallable("smartPlanRefresh", { timeout: 180000 });
          const { data } = await fn({ planId: activePlan.id });
          const after = data?.projection?.after?.tsb;
          const before = data?.projection?.before?.tsb;
          const band = data?.inTargetBand ? "in the +15–25 target band" : "as close to target as the plan allows";
          setSuccess(
            `Plan refreshed in one pass — +${data?.appliedBumpMi ?? "?"}mi/week through peak, ` +
            `${data?.taperWeeks ?? 2}-week taper. ` +
            (Number.isFinite(after)
              ? `Projected race-day TSB ${before != null ? `${before} → ` : ""}${after} (${band}).`
              : "")
          );
          setTimeout(() => setSuccess(null), 8000);
        } catch (e) {
          console.error("[smartRefreshPlan]", e);
          setError("Smart refresh failed: " + (e?.message || "unknown"));
          setTimeout(() => setError(null), 6000);
        } finally {
          setSmartRefreshing(false);
        }
      };

      const refreshEntirePlan = async ({ skipConfirm = false } = {}) => {
        if (!activePlan?.weeks?.length) return;
        const total = activePlan.weeks.length;
        if (!skipConfirm) {
          const confirmed = confirm(
            `Refresh ${total} week${total === 1 ? "" : "s"} of your plan?\n\n` +
            `• Completed and skipped days are preserved.\n` +
            `• Every other day is regenerated with the latest rules\n` +
            `  (progressive overload ±10%, long-run anchor, HR zones,\n` +
            `  treadmill structure, indoor/outdoor variants).\n\n` +
            `This calls the AI coach once per week (~10-30s each), so a ` +
            `${total}-week plan will take a few minutes. You can leave the ` +
            `tab open and watch the progress.`
          );
          if (!confirmed) return;
        }
        setError(null);
        setSuccess(skipConfirm
          ? "Goal updated — refreshing plan workouts to the new pace (completed & skipped days kept)…"
          : null);
        let refreshed = 0;
        let skipped = 0;
        const RUN_TYPES_REFRESH = ["easy","recovery","tempo","intervals","long","race_pace","race"];
        // Lazy day-of-week lookup for Strava matching inside the loop.
        const dayOfWeekMap = { Monday:1, Tuesday:2, Wednesday:3, Thursday:4, Friday:5, Saturday:6, Sunday:0 };
        for (let i = 0; i < total; i++) {
          // Re-read each iteration because earlier refreshes mutate activePlan.
          const week = activePlan.weeks[i];
          const hasOpenDay = (week.days || []).some(d => !d.completed && !d.skipped && !d.manuallyEdited);
          if (!hasOpenDay) {
            // All days locked — no AI call needed. Two tasks:
            //   1. Backfill actualMi on each completed day from its matched
            //      Strava activity when missing, so sumRunMiles reflects
            //      lived reality instead of the stale planned distanceMi.
            //   2. Resync week.targetMiles. For a locked week we use
            //      getAdjustedMiForWeek (Strava weekly total) as the
            //      authoritative value — this bypasses any gaps in the
            //      per-day actualMi backfill (e.g. day-of-week
            //      mismatches, runs that didn't auto-link to a planned
            //      day). Per-day actualMi is still backfilled where
            //      possible for the card-level display.
            const weekComp = getCompletionForWeek(activePlan, i + 1);
            const patchedDays = week.days.map(d => {
              if (!d.completed) return d;
              if (typeof d.actualMi === "number" && d.actualMi > 0) return d;
              const linked = d.linkedActivityId ? activities.find(a => a.id === d.linkedActivityId) : null;
              const matched = linked
                ? aggregateDayActivities([linked])
                : aggregateDayActivities(getDayActivities(weekComp, d.day, d.type, dayOfWeekMap));
              if (matched && matched.distance > 0 && RUN_TYPES_REFRESH.includes(d.type)) {
                return { ...d, actualMi: Math.round((matched.distance / 1609.34) * 10) / 10 };
              }
              return d;
            });
            const stravaWeeklyMi = getAdjustedMiForWeek(activePlan, i + 1);
            const authoritativeTarget = stravaWeeklyMi > 0
              ? Math.round(stravaWeeklyMi)
              : recomputeWeekTotals({ ...week, days: patchedDays }).targetMiles;
            const resynced = { ...week, days: patchedDays, targetMiles: authoritativeTarget };
            const daysChanged = patchedDays.some((d, k) => d !== week.days[k]);
            const totalChanged = (week.targetMiles || 0) !== resynced.targetMiles;
            if (daysChanged || totalChanged) {
              const weeks = activePlan.weeks.map((w, wi) => wi === i ? resynced : w);
              try {
                await db.collection("users").doc(userId).collection("trainingPlans").doc(activePlan.id)
                  .update({ weeks, updatedAt: firebase.firestore.FieldValue.serverTimestamp() });
                refreshed++;
              } catch (err) {
                console.warn(`[refreshEntirePlan] week ${i + 1} header resync failed:`, err);
                skipped++;
              }
            } else {
              skipped++;
            }
            setRefreshingPlan({ current: i + 1, total, refreshed, skipped });
            continue;
          }
          setRefreshingPlan({ current: i + 1, total, refreshed, skipped });
          try {
            await generateWeek({ planId: activePlan.id, weekNum: i + 1, replace: true });
            refreshed++;
          } catch (err) {
            console.error(`[refreshEntirePlan] week ${i + 1} failed:`, err);
            setError(`Week ${i + 1} refresh failed: ${err.message}. Stopped after ${refreshed} week(s).`);
            setRefreshingPlan(null);
            return;
          }
        }
        setRefreshingPlan(null);
        setSuccess(
          `Plan refreshed — ${refreshed} week${refreshed === 1 ? "" : "s"} regenerated` +
          (skipped > 0 ? `, ${skipped} skipped (already fully done)` : "") + "."
        );
      };

      // Goal edit on the Status hero hands off here: once the plan has
      // loaded, consume the one-shot request and Smart-Refresh every
      // non-completed week so the prescribed paces rebuild against the
      // new goal. Guarded so the in-flight Firestore writes (which
      // re-fire this effect via the plan subscription) can't re-trigger it.
      const planRefreshRequestHandled = useRef(false);
      useEffect(() => {
        if (planRefreshRequestHandled.current) return;
        if (!isOwner) return;
        if (!activePlan?.weeks?.length) return;
        if (!consumePlanRefreshRequest || !consumePlanRefreshRequest()) return;
        planRefreshRequestHandled.current = true;
        refreshEntirePlan({ skipConfirm: true });
        // eslint-disable-next-line react-hooks/exhaustive-deps
      }, [activePlan, isOwner]);

      // Refresh a single day
      const refreshDay = async (planId, weekIdx, dayIdx) => {
        const key = `${weekIdx}-${dayIdx}`;
        const priorDay = activePlan?.weeks?.[weekIdx]?.days?.[dayIdx];
        const hadGarminPush = !!priorDay?.garminWorkoutId;
        setGeneratingDay(key);
        setError(null);
        try {
          const fn = functions.httpsCallable("generateTrainingDay", { timeout: 300000 });
          await fn({ planId, weekIdx, dayIdx, weather: indoor ? null : weather, indoor });
          setSuccess("Day refreshed!");
          // Auto-repush to Garmin if this day had been pushed before.
          // Done after the success message so the user sees the refresh
          // lands; the repush is a follow-on.
          if (hadGarminPush) {
            pushToGarmin(weekIdx, dayIdx).catch(err => console.warn("[plan] auto-repush after refresh failed:", err?.message || err));
          }
        } catch (err) {
          setError("Failed to refresh day: " + err.message);
        }
        setGeneratingDay(null);
      };

      // Swap two days within a week (saved immediately to Firestore)
      const moveDay = async (planId, weekIdx, dayIdx, direction) => {
        const dayNames = ["Monday","Tuesday","Wednesday","Thursday","Friday","Saturday","Sunday"];
        const days = [...activePlan.weeks[weekIdx].days];
        const currentDay = days[dayIdx];
        const currentDayIndex = dayNames.indexOf(currentDay.day);
        const targetDayIndex = currentDayIndex + direction;
        if (targetDayIndex < 0 || targetDayIndex > 6) return;
        const targetDayName = dayNames[targetDayIndex];
        // Find the workout currently on the target day
        const targetIdx = days.findIndex(d => d.day === targetDayName);
        if (targetIdx >= 0) {
          // Swap day names between the two workouts
          days[dayIdx] = { ...days[dayIdx], day: targetDayName };
          days[targetIdx] = { ...days[targetIdx], day: dayNames[currentDayIndex] };
        } else {
          // No workout on target day, just reassign
          days[dayIdx] = { ...days[dayIdx], day: targetDayName };
        }
        // Sort by day order
        days.sort((a, b) => dayNames.indexOf(a.day) - dayNames.indexOf(b.day));
        const weeks = activePlan.weeks.map((w, wi) => wi === weekIdx ? recomputeWeekTotals({ ...w, days }) : w);
        await db.collection("users").doc(userId).collection("trainingPlans").doc(planId)
          .update({ weeks, updatedAt: firebase.firestore.FieldValue.serverTimestamp() });
      };

      // Directly move a workout to any chosen day (used by the Day dropdown in the edit panel)
      const changeDayTo = async (planId, weekIdx, dayIdx, newDayName) => {
        const dayNames = ["Monday","Tuesday","Wednesday","Thursday","Friday","Saturday","Sunday"];
        const days = [...activePlan.weeks[weekIdx].days];
        const currentDay = days[dayIdx];
        if (currentDay.day === newDayName) return;
        const targetIdx = days.findIndex(d => d.day === newDayName);
        if (targetIdx >= 0) {
          // Swap day names
          days[dayIdx] = { ...days[dayIdx], day: newDayName };
          days[targetIdx] = { ...days[targetIdx], day: currentDay.day };
        } else {
          days[dayIdx] = { ...days[dayIdx], day: newDayName };
        }
        days.sort((a, b) => dayNames.indexOf(a.day) - dayNames.indexOf(b.day));
        const weeks = activePlan.weeks.map((w, wi) => wi === weekIdx ? recomputeWeekTotals({ ...w, days }) : w);
        await db.collection("users").doc(userId).collection("trainingPlans").doc(planId)
          .update({ weeks, updatedAt: firebase.firestore.FieldValue.serverTimestamp() });
        setEditingDay(null); // close edit panel since dayIdx may have shifted
      };

      // ── Google Calendar sync ──────────────────────────────────────────────────
      const GCAL_SCOPE = "https://www.googleapis.com/auth/calendar.events";
      const [gcalToken, setGcalToken] = useState(null);
      const [syncingCalendar, setSyncingCalendar] = useState(false);
      const gcalTokenClientRef = useRef(null);

      // Calculate a workout's calendar date from plan start + week + day
      const getWorkoutDate = (plan, weekNum, dayName) => {
        const DAY_OFFSETS = { Monday:0, Tuesday:1, Wednesday:2, Thursday:3, Friday:4, Saturday:5, Sunday:6 };
        const created = plan.createdAt?.seconds ? new Date(plan.createdAt.seconds * 1000) : new Date(plan.createdAt);
        // Align to the Monday of the creation week
        const dow = created.getDay(); // 0=Sun 1=Mon…
        const daysToMon = dow === 1 ? 0 : dow === 0 ? -6 : 1 - dow;
        const planMonday = new Date(created);
        planMonday.setDate(planMonday.getDate() + daysToMon);
        planMonday.setHours(0,0,0,0);
        const totalDays = (weekNum - 1) * 7 + (DAY_OFFSETS[dayName] || 0);
        const d = new Date(planMonday.getTime() + totalDays * 86400000);
        return d.toISOString().split("T")[0];
      };

      const workoutToCalendarEvent = (day, date, weekNum) => {
        const GCAL_COLORS = { easy:"2", recovery:"2", tempo:"6", intervals:"11", long:"7", race_pace:"9", cross_train:"1", strength:"5", rest:"8" };
        const TYPE_EMOJI = { easy:"🏃", recovery:"🚶", tempo:"⚡", intervals:"🔥", long:"🏔️", race_pace:"🎯", cross_train:"🚴", strength:"💪", rest:"😴" };
        const emoji = TYPE_EMOJI[day.type] || "🏃";
        const typeName = (day.type || "workout").replace("_"," ");
        let title = `${emoji} ${typeName.charAt(0).toUpperCase()+typeName.slice(1)}`;
        if (day.distanceMi > 0) title += ` · ${day.distanceMi}mi`;
        if (day.durationMin && !day.distanceMi) title += ` · ${day.durationMin}min`;
        const lines = [day.description || ""];
        if (day.targetPace) lines.push(`Pace: ${day.targetPace}/mi`);
        const garminZoneNum = (() => { const z = day.hrZone; if (!z) return null; if (typeof z === "number") return z; const m = String(z).match(/\d+/); return m ? parseInt(m[0]) : null; })();
        const liveHRZone = garminZoneNum && hrZones?.zones ? hrZones.zones.find(z => z.zone === garminZoneNum) : null;
        if (liveHRZone) lines.push(`HR: ${liveHRZone.minBPM}–${liveHRZone.maxBPM} bpm (Z${garminZoneNum})`);
        else if (day.targetHR) lines.push(`HR: ${day.targetHR.low}–${day.targetHR.high} bpm`);
        if (day.durationMin) lines.push(`Duration: ${day.durationMin} min`);
        if (day.nutrition) lines.push(`Fuel: ${typeof day.nutrition === "string" ? day.nutrition : JSON.stringify(day.nutrition)}`);
        lines.push(`\nWeek ${weekNum} — chris.run training plan`);
        return {
          summary: title,
          description: lines.filter(Boolean).join("\n"),
          start: { date },
          end: { date },
          colorId: GCAL_COLORS[day.type] || "8",
          extendedProperties: { private: { chris_run: "1", weekNum: String(weekNum), type: day.type || "" } },
        };
      };

      const doCalendarSync = async (plan, token, onlyWeekNum = null) => {
        setSyncingCalendar(true);
        let created = 0, skipped = 0;
        for (const week of (plan.weeks || [])) {
          if (onlyWeekNum && week.week !== onlyWeekNum) continue;
          for (const day of (week.days || [])) {
            if (day.type === "rest") { skipped++; continue; }
            try {
              const date = getWorkoutDate(plan, week.week, day.day);
              const event = workoutToCalendarEvent(day, date, week.week);
              const resp = await fetch("https://www.googleapis.com/calendar/v3/calendars/primary/events", {
                method: "POST",
                headers: { "Authorization": `Bearer ${token}`, "Content-Type": "application/json" },
                body: JSON.stringify(event),
              });
              if (resp.ok) created++;
              else skipped++;
            } catch { skipped++; }
          }
        }
        setSyncingCalendar(false);
        setSuccess(`Added ${created} workouts to Google Calendar${skipped ? ` (${skipped} skipped)` : ""}!`);
      };

      const syncToGoogleCalendar = (plan, onlyWeekNum = null) => {
        if (!plan) return;
        // Initialise GIS token client lazily
        if (!gcalTokenClientRef.current && typeof google !== "undefined" && google.accounts?.oauth2) {
          gcalTokenClientRef.current = google.accounts.oauth2.initTokenClient({
            client_id: GOOGLE_CLIENT_ID,
            scope: GCAL_SCOPE,
            callback: async (resp) => {
              if (resp.error) { setError("Google Calendar auth failed: " + resp.error); return; }
              setGcalToken(resp.access_token);
              await doCalendarSync(plan, resp.access_token, onlyWeekNum);
            },
          });
        }
        if (gcalToken) {
          // Already have a token — try using it directly
          doCalendarSync(plan, gcalToken, onlyWeekNum);
        } else if (gcalTokenClientRef.current) {
          gcalTokenClientRef.current.requestAccessToken({ prompt: "consent" });
        } else {
          setError("Google Calendar unavailable — please ensure pop-ups are allowed.");
        }
      };

      // Save inline day edit
      // Week totals MUST always reflect the actual sum of planned running
      // miles across the days — otherwise the "17.2 / 22 mi" header drifts
      // from what's shown below. recomputeWeekTotals lives in
      // running/lib/planMath.js so it can be unit-tested from Node
      // alongside the backend R9 rule.
      const recomputeWeekTotals = window.PlanMath.recomputeWeekTotals;
      const updateWeekDays = (weekIdx, transformDay) => {
        return activePlan.weeks.map((w, wi) => {
          if (wi !== weekIdx) return w;
          const days = w.days.map(transformDay);
          return recomputeWeekTotals({ ...w, days });
        });
      };

      const saveEditDay = async (planId, weekIdx, dayIdx, dayData) => {
        const priorDay = activePlan?.weeks?.[weekIdx]?.days?.[dayIdx];
        const hadGarminPush = !!priorDay?.garminWorkoutId;
        // Stamp a manuallyEdited flag so Smart Refresh preserves the
        // edit in the same lockedDays branch that already protects
        // completed / skipped days. Without this the next refresh
        // regenerates the slot from the AI prompt and silently throws
        // away the athlete's hand-tuned distance / pace / type — e.g.
        // a tempo bumped 6→7mi for an extra-mile pre-jump-week
        // disappears entirely on refresh.
        const editedDayData = { ...dayData, manuallyEdited: true, manualEditAt: Date.now() };
        const weeks = updateWeekDays(weekIdx, (d, di) => di === dayIdx ? { ...d, ...editedDayData } : d);
        await db.collection("users").doc(userId).collection("trainingPlans").doc(planId)
          .update({ weeks, updatedAt: firebase.firestore.FieldValue.serverTimestamp() });
        setEditingDay(null);
        setSuccess("Day updated!");
        // Auto-repush if this day was previously pushed to Garmin. The
        // backend deletes the old workout first so the library doesn't
        // accumulate stale copies.
        if (hadGarminPush) {
          pushToGarmin(weekIdx, dayIdx).catch(err => console.warn("[plan] auto-repush after edit failed:", err?.message || err));
        }
      };

      // Structured-interval editor support. The quality types are the
      // ones that carry repeats; for those a filled-in intervalSpec is
      // expanded by window.PlanMath.buildIntervalWorkout into synced
      // steps[] / description / distanceMi / targetHR / targetPace right
      // before saveEditDay stamps manuallyEdited (so it survives Smart
      // Refresh and the goal-triggered auto-refresh).
      const STRUCTURED_TYPES = ["intervals", "tempo", "race_pace"];
      const DEFAULT_INTERVAL_SPEC = {
        enabled: false, warmupMi: 1.5, reps: 4, workVal: 6, workUnit: "min",
        workPace: "", hrLow: "", hrHigh: "", recVal: 90, recUnit: "sec",
        recJog: true, cooldownMi: 1.5,
      };
      const updateSpec = (patch) => setEditingDay(prev => prev && ({
        ...prev,
        data: { ...prev.data, intervalSpec: { ...DEFAULT_INTERVAL_SPEC, ...prev.data.intervalSpec, ...patch } },
      }));
      const saveDayWithStructure = (planId, weekIdx, dayIdx, data) => {
        let out = data;
        const spec = data.intervalSpec;
        if (spec && spec.enabled && STRUCTURED_TYPES.includes(data.type)) {
          const built = window.PlanMath.buildIntervalWorkout(spec, { type: data.type });
          if (built) {
            // Duration reps with no work pace can't estimate work
            // mileage — don't let the week header collapse to WU+CD.
            const distanceMi = (spec.workUnit === "min" && !built.targetPace)
              ? Math.max(built.distanceMi, Number(data.distanceMi) || 0)
              : built.distanceMi;
            out = {
              ...data,
              steps: built.steps,
              description: built.description,
              distanceMi,
              targetHR: built.targetHR || data.targetHR || null,
              targetPace: built.targetPace || data.targetPace || null,
            };
          }
        }
        return saveEditDay(planId, weekIdx, dayIdx, out);
      };

      // Toggle a running day between its pre-generated variants (indoor,
      // outdoor, or track — the track variant only exists for quality
      // workouts: tempo, intervals, race_pace). Mirrors the backend
      // switchDayVariant() logic so this is an instant Firestore write
      // with no plan regeneration.
      const switchDayEnvironment = async (planId, weekIdx, dayIdx, target) => {
        const week = activePlan.weeks[weekIdx];
        const day = week?.days?.[dayIdx];
        if (!day?.variants?.[target]) return;
        const currentKey = day.activeVariant || (day.venue === "track" ? "track" : (day.indoor ? "indoor" : "outdoor"));
        if (currentKey === target) return;
        // Preserve any in-place top-level edits back into the current variant slot.
        const currentSnap = {
          description: day.description || "",
          targetPace: day.targetPace ?? null,
          indoor: !!day.indoor,
          treadmill: day.treadmill ?? null,
          venue: day.venue === "track" ? "track" : null,
        };
        const src = day.variants[target];
        const newDay = {
          ...day,
          variants: { ...day.variants, [currentKey]: currentSnap },
          activeVariant: target,
          description: src.description || "",
          targetPace: src.targetPace ?? null,
          indoor: !!src.indoor,
          treadmill: src.treadmill ?? null,
          venue: src.venue === "track" ? "track" : null,
        };
        const weeks = updateWeekDays(weekIdx, (d, di) => di === dayIdx ? newDay : d);
        await db.collection("users").doc(userId).collection("trainingPlans").doc(planId)
          .update({ weeks, updatedAt: firebase.firestore.FieldValue.serverTimestamp() });
      };

      // Fire-and-forget Garmin cleanup when a day is marked complete via
      // any path (toggle, manual log, activity link). Server clears the
      // local garminWorkoutId pointer too, so a stale "pushed" indicator
      // doesn't linger after the workout is removed from Garmin's library.
      const cleanupGarminForCompletedDay = (planId, weekIdx, dayIdx, day) => {
        if (!day?.garminWorkoutId) return;
        try {
          const fn = functions.httpsCallable("deleteGarminWorkoutForDay");
          fn({ planId, weekIdx, dayIdx }).catch(err =>
            console.warn("[garmin] cleanup on complete failed:", err?.message || err));
        } catch (err) {
          console.warn("[garmin] cleanup invoke failed:", err?.message || err);
        }
      };

      const toggleDayComplete = async (planId, weekIdx, dayIdx, matchedAct) => {
        const dayBefore = activePlan?.weeks?.[weekIdx]?.days?.[dayIdx];
        const wasCompleted = !!dayBefore?.completed;
        const weeks = updateWeekDays(weekIdx, (d, di) => {
          if (di !== dayIdx) return d;
          const nowCompleted = !d.completed;
          if (!nowCompleted) {
            // Un-completing: clear stored grade AND the actualMi we stamped
            // at completion so the week header reverts to planned distance.
            const { completedGrade: _cg, completedScore: _cs, completedGradeDetails: _cgd, actualMi: _am, ...rest } = d;
            return { ...rest, completed: false, skipped: false };
          }
          // Completing: snapshot grade AND actualMi now so they're immutable
          // from this point forward. Previously only the grade was stamped,
          // which meant the week-total header kept showing the stale
          // planned distance (e.g. 33 mi) even after the user ran 26.6 mi.
          const exec = matchedAct ? calcExecutionScore(d, matchedAct) : null;
          const actualMi = matchedAct && matchedAct.distance > 0
            ? Math.round((matchedAct.distance / 1609.34) * 10) / 10
            : d.actualMi;
          return {
            ...d,
            completed: true,
            skipped: false,
            ...(actualMi != null ? { actualMi } : {}),
            ...(exec ? {
              completedGrade: exec.grade,
              completedScore: exec.score,
              completedGradeDetails: exec.details,
            } : {}),
          };
        });
        await db.collection("users").doc(userId).collection("trainingPlans").doc(planId)
          .update({ weeks, updatedAt: firebase.firestore.FieldValue.serverTimestamp() });
        // Only clean up Garmin when transitioning from incomplete → complete.
        if (!wasCompleted) cleanupGarminForCompletedDay(planId, weekIdx, dayIdx, dayBefore);
      };

      const skipDay = async (planId, weekIdx, dayIdx) => {
        const weeks = updateWeekDays(weekIdx, (d, di) => di === dayIdx ? { ...d, skipped: !d.skipped, completed: false } : d);
        await db.collection("users").doc(userId).collection("trainingPlans").doc(planId)
          .update({ weeks, updatedAt: firebase.firestore.FieldValue.serverTimestamp() });
      };

      // Toggle a planned workout day to/from rest — clears distance/pace targets when set to rest
      const setDayRest = async (planId, weekIdx, dayIdx) => {
        const day = activePlan.weeks[weekIdx]?.days?.[dayIdx];
        if (!day) return;
        const isAlreadyRest = day.type === "rest";
        const weeks = updateWeekDays(weekIdx, (d, di) => {
          if (di !== dayIdx) return d;
          if (isAlreadyRest) {
            return { ...d, type: "easy", skipped: false, completed: false };
          }
          const { linkedActivityId: _l, distanceMi: _dm, targetPace: _tp, targetHR: _thr, hrZone: _hz, ...rest } = d;
          return { ...rest, type: "rest", skipped: false, completed: false, description: "Rest day" };
        });
        await db.collection("users").doc(userId).collection("trainingPlans").doc(planId)
          .update({ weeks, updatedAt: firebase.firestore.FieldValue.serverTimestamp() });
      };

      const logManualActivity = async (planId, weekIdx, dayIdx) => {
        const distMi = parseFloat(logForm.distanceMi) || 0;
        const durMin = parseFloat(logForm.durationMin) || 0;
        const avgHR  = parseFloat(logForm.avgHR) || null;
        if (!distMi && !durMin) { setError("Enter at least a distance or duration"); return; }

        const manualActivity = {
          distanceMi: distMi || null,
          durationMin: durMin || null,
          avgHR: avgHR || null,
          notes: logForm.notes.trim() || null,
          loggedAt: new Date().toISOString(),
        };

        // Build a synthetic matchedActivity in the same shape calcExecutionScore expects
        const syntheticMatch = {
          distance: distMi * 1609.34,
          moving_time: durMin * 60,
          average_heartrate: avgHR,
          _count: 1,
          _manual: true,
        };

        const dayBefore = activePlan?.weeks?.[weekIdx]?.days?.[dayIdx];
        const weeks = updateWeekDays(weekIdx, (d, di) => {
          if (di !== dayIdx) return d;
          const exec = calcExecutionScore(d, syntheticMatch);
          return {
            ...d,
            completed: true,
            skipped: false,
            manualActivity,
            actualMi: distMi || d.actualMi || null,
            ...(exec ? {
              completedGrade: exec.grade,
              completedScore: exec.score,
              completedGradeDetails: exec.details,
            } : {}),
          };
        });

        await db.collection("users").doc(userId).collection("trainingPlans").doc(planId)
          .update({ weeks, updatedAt: firebase.firestore.FieldValue.serverTimestamp() });
        if (!dayBefore?.completed) cleanupGarminForCompletedDay(planId, weekIdx, dayIdx, dayBefore);
        setLoggingDay(null);
        setLogForm({ distanceMi: "", durationMin: "", avgHR: "", notes: "" });
      };

      const linkActivityToDay = async (planId, weekIdx, dayIdx, activity) => {
        const dayBefore = activePlan?.weeks?.[weekIdx]?.days?.[dayIdx];
        const agg = aggregateDayActivities([activity]);
        const weeks = updateWeekDays(weekIdx, (d, di) => {
          if (di !== dayIdx) return d;
          const exec = agg ? calcExecutionScore(d, agg) : null;
          return {
            ...d,
            linkedActivityId: activity.id,
            completed: true,
            skipped: false,
            actualMi: agg ? Math.round(agg.distance / 1609.34 * 10) / 10 : d.actualMi,
            ...(exec ? { completedGrade: exec.grade, completedScore: exec.score, completedGradeDetails: exec.details } : {}),
          };
        });
        await db.collection("users").doc(userId).collection("trainingPlans").doc(planId)
          .update({ weeks, updatedAt: firebase.firestore.FieldValue.serverTimestamp() });
        if (!dayBefore?.completed) cleanupGarminForCompletedDay(planId, weekIdx, dayIdx, dayBefore);
        setLinkingDay(null);
      };

      // Persist a free-text athlete note onto a plan day. Surfaced to
      // Coach Analysis via coachContext.formatActivityPlanContext so the
      // coach grades the run with the athlete's subjective context in
      // hand. Preserved across Smart-Refresh / generateTrainingDay so
      // regenerating a workout doesn't wipe the comment.
      const saveAthleteNote = async (planId, weekIdx, dayIdx, note) => {
        const trimmed = typeof note === "string" ? note.trim() : "";
        const weeks = updateWeekDays(weekIdx, (d, di) => {
          if (di !== dayIdx) return d;
          if (!trimmed) {
            const { athleteNote: _, ...rest } = d;
            return rest;
          }
          return { ...d, athleteNote: trimmed };
        });
        await db.collection("users").doc(userId).collection("trainingPlans").doc(planId)
          .update({ weeks, updatedAt: firebase.firestore.FieldValue.serverTimestamp() });
      };

      const unlinkActivityFromDay = async (planId, weekIdx, dayIdx) => {
        const weeks = updateWeekDays(weekIdx, (d, di) => {
          if (di !== dayIdx) return d;
          // Keep `completed` as-is. Auto-clearing it was symmetric with
          // linkActivityToDay's auto-set, but it broke an
          // unlink → Smart-Refresh → re-link flow: Smart-Refresh's
          // lockedDays filter only protects completed / skipped /
          // manuallyEdited days, so the just-unlinked day was treated
          // as open, the AI regenerated its prescription (wiping the
          // workout the athlete actually did), and the redistribution
          // pass resized neighbouring easy days. The link is severed
          // but the "I did this workout" intent stays put — the
          // checkbox toggle is the explicit way to walk it back.
          const { linkedActivityId: _, completedGrade: _cg, completedScore: _cs, completedGradeDetails: _cgd, ...rest } = d;
          return rest;
        });
        await db.collection("users").doc(userId).collection("trainingPlans").doc(planId)
          .update({ weeks, updatedAt: firebase.firestore.FieldValue.serverTimestamp() });
      };

      const suggestHarderSession = async (weekIdx) => {
        if (!activePlan) return;
        setGeneratingDay(`suggest-${weekIdx}`);
        try {
          const fn = functions.httpsCallable("generateTrainingDay", { timeout: 300000 });
          const week = activePlan.weeks[weekIdx];
          // Find the best easy/recovery day to replace (not completed, not skipped, not long run)
          const replaceIdx = (week.days || []).findIndex(d =>
            (d.type === "easy" || d.type === "recovery") && !d.completed && !d.skipped
          );
          const result = await fn({
            planId: activePlan.id,
            weekIdx,
            request: `Replace ${replaceIdx >= 0 ? week.days[replaceIdx].day + "'s easy run" : "an easy day"} with a harder quality session. Keep the same day name. Consider the current week load and suggest a tempo, threshold, or hill session. Return a single day object.`,
            currentWeek: week,
          });
          if (result.data?.day) {
            const newDay = result.data.day;
            const weeks = activePlan.weeks.map((w, wi) => {
              if (wi !== weekIdx) return w;
              const days = [...(w.days || [])];
              if (replaceIdx >= 0) {
                newDay.day = days[replaceIdx].day; // keep the same day name
                days[replaceIdx] = newDay;
              } else {
                // No easy day to replace — find first non-completed, non-skipped day
                const fallbackIdx = days.findIndex(d => !d.completed && !d.skipped && d.type !== "long" && d.type !== "rest");
                if (fallbackIdx >= 0) { newDay.day = days[fallbackIdx].day; days[fallbackIdx] = newDay; }
              }
              return { ...w, days };
            });
            await db.collection("users").doc(userId).collection("trainingPlans").doc(activePlan.id)
              .update({ weeks, updatedAt: firebase.firestore.FieldValue.serverTimestamp() });
            setSuccess("Easy day replaced with harder session!");
          }
        } catch (err) {
          setError("Failed to suggest session: " + err.message);
        }
        setGeneratingDay(null);
      };

      // Coach-triggered plan actions (generate_week / cascade_refresh in
      // users/{uid}/coachActions) are processed SERVER-SIDE by the
      // processCoachAction Firestore trigger — the old client listener here
      // only ran while this tab was mounted, so a coach-queued refresh
      // started from the Coach page silently never executed. Progress is
      // rendered live in the coach chat (see usePlanJobs in src/coach.jsx);
      // the plan updates stream in through the normal plan subscription.

      // Legacy alias
      const generatePlan = (adjustMode = false) => generateWeek(adjustMode && activePlan ? { planId: activePlan.id, weekNum: (activePlan.weeks?.length || 0) + 1 } : {});

      const deletePlan = async (planId) => {
        if (!confirm("Delete this training plan?")) return;
        await db.collection("users").doc(userId).collection("trainingPlans").doc(planId).delete();
        if (activePlan?.id === planId) setActivePlan(null);
      };

      // Push a single day to Garmin Connect. Backend builds the workout
      // payload from the stored plan day (functions/lib/garminWorkout.js)
      // and also attempts to schedule it on the calendar for its plan
      // date, then persists the returned garminWorkoutId back onto the
      // day so edits / refreshes can replace the Garmin copy instead of
      // creating duplicates.
      const pushToGarmin = async (wi, di) => {
        const day = activePlan?.weeks?.[wi]?.days?.[di];
        if (!day || day.type === "rest" || day.skipped) return;
        setPushingWorkout(day.day + wi);
        try {
          const fn = functions.httpsCallable("pushWorkoutToGarmin");
          const res = await fn({ planId: activePlan.id, weekIdx: wi, dayIdx: di });
          const d = res?.data || {};
          if (d.skipped) {
            setError("Garmin push skipped: " + (d.reason || "not a runnable day"));
          } else {
            setSuccess(`Workout pushed to Garmin${d.scheduledDate ? ` (scheduled ${d.scheduledDate})` : ""}`);
          }
        } catch (err) {
          // Surface session-expired errors with an actionable hint rather
          // than just "push failed". Tokens last ~30 days; user has to
          // re-auth via Settings → Garmin Connect → Send MFA Code.
          const msg = String(err?.message || err || "");
          if (/no valid garmin session|re-authenticate via the app|MFA/i.test(msg)) {
            setError("Garmin OAuth session expired. Open Settings → Garmin Connect and tap 'Send MFA Code' to reconnect, then retry the push.");
          } else {
            setError("Garmin push failed: " + msg);
          }
        }
        setPushingWorkout(null);
      };

      // Push an entire week of runnable workouts. Rest / strength /
      // cross_train / skipped days are filtered server-side. Days that
      // were previously pushed have their Garmin workout replaced (old
      // one is deleted first so the library stays clean). Each workout
      // is scheduled on its plan calendar date best-effort.
      // Browser-session push: mint a short-lived token + staged workout
      // payloads on the backend, then open connect.garmin.com with the
      // token in the URL hash. The Tampermonkey userscript (installed via
      // Settings → Auto-Sync Extension) picks up the token, POSTs each
      // workout to Garmin's /workout-service endpoint using the user's
      // logged-in session cookies, schedules to the calendar date, and
      // reports results back to chris.run which writes workoutIds onto
      // the plan days. No server-side OAuth / MFA / IP rate-limit path.
      const pushWeekViaBrowser = async (wi) => {
        if (!activePlan) return;
        setPushingWeek(wi);
        try {
          const fn = functions.httpsCallable("garminWorkoutSyncPrep");
          const res = await fn({ planId: activePlan.id, weekIdx: wi });
          const d = res?.data || {};
          if (!d.token) throw new Error("backend did not return a token");
          if (!d.count) {
            setError(`Week ${wi + 1} has no runnable days to push (rest / strength / cross_train only).`);
            setPushingWeek(null);
            return;
          }
          setSuccess(`Staged ${d.count} workouts. Opening Garmin Connect — the userscript will push + schedule them from your logged-in session.`);
          // Hash-based token (not query) so Garmin's server never sees it in logs.
          // Open the dashboard URL directly with the token in the query.
          // Earlier attempts:
          //   `/#crPushToken=X` → Garmin's SPA mangled it into a /app/...
          //     path segment and 404'd.
          //   `/?crPushToken=X` → Garmin server-side 302 redirects "/" to
          //     "/app/home" and strips the query string before the page
          //     loads, so document-start fires on the redirect target with
          //     no token.
          // Going straight to /app/home keeps the query string intact
          // through the initial load; the userscript reads it at
          // document-start before the SPA can history.replaceState it away.
          const url = "https://connect.garmin.com/app/home?crPushToken=" + encodeURIComponent(d.token);
          window.open(url, "_blank");
        } catch (err) {
          setError("Browser push prep failed: " + (err.message || err));
        }
        setPushingWeek(null);
      };

      // Bulk-clean orphaned chris.run workouts from Garmin's library.
      // Garmin has no bulk-delete UI, so re-push duplicates accumulate
      // across regenerations. Mirrors the Push Week handshake — server
      // mints a token + stages live workout IDs; we open Garmin in a
      // new tab; the Tampermonkey userscript enumerates the library
      // and deletes orphans using the user's session cookies (no OAuth
      // required).
      const cleanGarminLibraryUI = async () => {
        try {
          const fn = functions.httpsCallable("cleanGarminLibraryPrep");
          const res = await fn({});
          const d = res?.data || {};
          if (!d.token) throw new Error("backend did not return a token");
          setSuccess(`Cleanup staged (${d.liveCount} live workouts to keep). Opening Garmin Connect — the userscript will enumerate the library and delete orphans.`);
          const url = "https://connect.garmin.com/app/home?crCleanupToken=" + encodeURIComponent(d.token);
          window.open(url, "_blank");
        } catch (err) {
          const detail = err?.details ? ` (${typeof err.details === "string" ? err.details : JSON.stringify(err.details)})` : "";
          const code = err?.code ? `[${err.code}] ` : "";
          setError(`Clean Garmin library failed: ${code}${err?.message || "Unknown error"}${detail}`);
          console.error("[cleanGarminLibrary] failed:", err);
        }
      };

      const pushWeekToGarmin = async (wi) => {
        if (!activePlan) return;
        setPushingWeek(wi);
        try {
          const fn = functions.httpsCallable("pushWeekToGarmin");
          const res = await fn({ planId: activePlan.id, weekIdx: wi });
          const d = res?.data || {};
          const parts = [`${d.pushed || 0} pushed`];
          if (d.scheduled != null && d.pushed > 0) parts.push(`${d.scheduled} scheduled`);
          if (d.skipped > 0) parts.push(`${d.skipped} skipped`);
          if (d.failed > 0) parts.push(`${d.failed} failed`);
          if (d.failed > 0 && d.pushed === 0) {
            setError(`Week ${wi + 1} → Garmin: ${parts.join(" · ")}. Check Settings → Garmin Connect — OAuth session may have expired.`);
          } else {
            setSuccess(`Week ${wi + 1} → Garmin: ${parts.join(" · ")}`);
          }
        } catch (err) {
          const msg = String(err?.message || err || "");
          if (/no valid garmin session|re-authenticate via the app|MFA/i.test(msg)) {
            setError("Garmin OAuth session expired. Open Settings → Garmin Connect and tap 'Send MFA Code' to reconnect, then retry the push.");
          } else {
            setError("Week push failed: " + msg);
          }
        }
        setPushingWeek(null);
      };

      const typeColors = {
        rest: C.textMuted, easy: C.green, recovery: C.green,
        tempo: C.amber, intervals: C.red, long: C.cyan,
        race_pace: C.purple, cross_train: C.blue || "#60a5fa",
        strength: C.orange || "#f97316",
      };

      const typeIcons = {
        rest: "😴", easy: "🏃", recovery: "🚶", tempo: "⚡",
        intervals: "🔥", long: "🏔️", race_pace: "🎯", cross_train: "🏊",
        strength: "💪",
      };

      // Upcoming races (future only)
      const upcomingRaces = races.filter(r => new Date(r.date) > new Date());

      return (
        <div style={{display:"flex",flexDirection:"column",gap:20}}>
          {error && <div style={{padding:"10px 14px",background:C.red+"15",border:`1px solid ${C.red}40`,borderRadius:8,color:C.red,fontSize:13}}>{error}</div>}
          {success && <div style={{padding:"10px 14px",background:C.green+"15",border:`1px solid ${C.green}40`,borderRadius:8,color:C.green,fontSize:13}}>{success}</div>}

          {/* Create new plan */}
          {!activePlan && (
            <Card>
              <div style={{fontSize:14,fontWeight:700,color:C.text,marginBottom:12}}>Create Training Plan</div>

              {/* Indoor / Outdoor toggle */}
              <div style={{marginBottom:16}}>
                <div style={{fontSize:11,color:C.textMuted,textTransform:"uppercase",letterSpacing:"0.08em",marginBottom:8}}>Training Environment</div>
                <div style={{display:"flex",background:C.bg2,borderRadius:8,overflow:"hidden",border:`1px solid ${C.border}`,width:"fit-content"}}>
                  {[{label:"🌤 Outdoor", val:false},{label:"🏠 Indoor (Tread + Peloton)", val:true}].map(opt => (
                    <button key={String(opt.val)} onClick={() => setIndoor(opt.val)} style={{
                      padding:"7px 18px",border:"none",cursor:"pointer",fontSize:12,fontWeight:600,
                      fontFamily:"'IBM Plex Mono',monospace",transition:"all 0.15s",
                      background: indoor === opt.val ? C.cyan : "transparent",
                      color: indoor === opt.val ? C.bg0 : C.textMuted,
                    }}>{opt.label}</button>
                  ))}
                </div>
              </div>

              {/* Live weather display (outdoor only) */}
              {!indoor && (
                <div style={{marginBottom:16,padding:"10px 14px",background:C.bg2,borderRadius:8,border:`1px solid ${C.border}`,display:"flex",alignItems:"center",gap:16,flexWrap:"wrap"}}>
                  {weatherLoading ? (
                    <span style={{fontSize:12,color:C.textMuted}}>Fetching weather...</span>
                  ) : weather ? (
                    <>
                      <span style={{fontSize:20}}>{weather.tempF >= 80 ? "🌡️" : weather.tempF <= 35 ? "🥶" : weather.conditions?.toLowerCase().includes("rain") ? "🌧️" : "🌤"}</span>
                      <div>
                        <div style={{fontSize:14,fontWeight:700,color:weather.tempF > 75 ? C.red : weather.tempF < 35 ? C.cyan : C.green}}>{weather.tempF}°F</div>
                        <div style={{fontSize:11,color:C.textMuted}}>{weather.conditions} · {weather.wind} · {weather.humidity}% humidity</div>
                      </div>
                      {weather.tempF > 75 && <span style={{fontSize:11,color:C.red,padding:"2px 8px",background:C.red+"15",borderRadius:4}}>Paces will be adjusted for heat</span>}
                      {weather.tempF < 35 && <span style={{fontSize:11,color:C.cyan,padding:"2px 8px",background:C.cyan+"15",borderRadius:4}}>Cold weather gear recommended</span>}
                      {weather.conditions?.toLowerCase().includes("rain") && <span style={{fontSize:11,color:C.amber,padding:"2px 8px",background:C.amber+"15",borderRadius:4}}>Traction caution</span>}
                    </>
                  ) : (
                    <span style={{fontSize:12,color:C.textMuted}}>Weather unavailable — plan will use default conditions</span>
                  )}
                </div>
              )}
              {indoor && (
                <div style={{marginBottom:16,padding:"10px 14px",background:C.cyan+"10",borderRadius:8,border:`1px solid ${C.cyan}30`,fontSize:12,color:C.textDim}}>
                  Indoor mode: treadmill at 1% incline, Peloton bike. No weather adjustments applied.
                </div>
              )}

              {/* Plan Settings — toggleable variables */}
              <div style={{marginBottom:16,display:"flex",flexDirection:"column",gap:12}}>
                {/* HR Zone Type */}
                <div>
                  <div style={{fontSize:11,color:C.textMuted,textTransform:"uppercase",letterSpacing:"0.08em",marginBottom:6}}>HR Zone Method</div>
                  <div style={{display:"flex",background:C.bg2,borderRadius:8,overflow:"hidden",border:`1px solid ${C.border}`,width:"fit-content"}}>
                    {[{label:"% Max HR",val:"percentage_max"},{label:"Karvonen (HRR)",val:"karvonen"},{label:"LTHR",val:"lactate_threshold"}].map(opt => (
                      <button key={opt.val} onClick={() => { setHrZoneType(opt.val); if (userId) db.collection("users").doc(userId).collection("settings").doc("hrConfig").set({ hrMethod: opt.val }, { merge: true }); }} style={{
                        padding:"6px 14px",border:"none",cursor:"pointer",fontSize:11,fontWeight:600,
                        fontFamily:"'IBM Plex Mono',monospace",transition:"all 0.15s",
                        background: hrZoneType === opt.val ? C.cyan : "transparent",
                        color: hrZoneType === opt.val ? C.bg0 : C.textMuted,
                      }}>{opt.label}</button>
                    ))}
                  </div>
                  {hrZones && <div style={{fontSize:10,color:C.textDim,marginTop:4}}>Zones: {hrZones.zones?.map(z => `Z${z.zone}: ${z.minBPM}-${z.maxBPM}`).join(" | ")}</div>}
                </div>

                {/* Terrain / Intensity / Toggles row */}
                <div style={{display:"flex",gap:16,flexWrap:"wrap",alignItems:"flex-start"}}>
                  <div>
                    <div style={{fontSize:11,color:C.textMuted,textTransform:"uppercase",letterSpacing:"0.08em",marginBottom:6}}>Terrain</div>
                    <select value={planTerrain} onChange={e => { setPlanTerrain(e.target.value); savePlanSettings({terrain:e.target.value}); }}
                      style={{fontSize:11,padding:"5px 8px"}}>
                      <option value="mixed">Mixed</option>
                      <option value="road">Road</option>
                      <option value="trail">Trail</option>
                      <option value="track">Track</option>
                    </select>
                  </div>
                  <div>
                    <div style={{fontSize:11,color:C.textMuted,textTransform:"uppercase",letterSpacing:"0.08em",marginBottom:6}}>Intensity</div>
                    <select value={intensityBias} onChange={e => { setIntensityBias(e.target.value); savePlanSettings({intensityBias:e.target.value}); }}
                      style={{fontSize:11,padding:"5px 8px"}}>
                      <option value="conservative">Conservative</option>
                      <option value="balanced">Balanced</option>
                      <option value="aggressive">Aggressive</option>
                    </select>
                  </div>
                  <div style={{display:"flex",gap:12,alignItems:"center",paddingTop:18}}>
                    <label style={{display:"flex",alignItems:"center",gap:6,cursor:"pointer",fontSize:11,color:C.textMuted}}>
                      <input type="checkbox" checked={includeXT} onChange={e => { setIncludeXT(e.target.checked); savePlanSettings({includeXT:e.target.checked}); }}
                        style={{width:14,height:14,accentColor:C.cyan}} />
                      Cross-train
                    </label>
                    <label style={{display:"flex",alignItems:"center",gap:6,cursor:"pointer",fontSize:11,color:C.textMuted}}>
                      <input type="checkbox" checked={includeNutrition} onChange={e => { setIncludeNutrition(e.target.checked); savePlanSettings({includeNutrition:e.target.checked}); }}
                        style={{width:14,height:14,accentColor:C.cyan}} />
                      Nutrition
                    </label>
                  </div>
                  <div>
                    <div style={{fontSize:11,color:C.textMuted,textTransform:"uppercase",letterSpacing:"0.08em",marginBottom:6}}>Run Days</div>
                    <div style={{display:"flex",gap:3}}>
                      {["Mon","Tue","Wed","Thu","Fri","Sat","Sun"].map(d => {
                        const active = runDays.includes(d);
                        return (
                          <button key={d} onClick={() => {
                            const next = active ? runDays.filter(x => x !== d) : [...runDays, d];
                            setRunDays(next); savePlanSettings({runDays: next});
                          }} style={{
                            fontSize:10,padding:"4px 6px",borderRadius:4,cursor:"pointer",fontWeight:600,
                            border:`1px solid ${active ? C.cyan : C.border}`,
                            background: active ? C.cyan+"20" : "transparent",
                            color: active ? C.cyan : C.textMuted,
                          }}>{d}</button>
                        );
                      })}
                    </div>
                  </div>
                </div>
              </div>

              {upcomingRaces.length === 0 ? (
                <div style={{color:C.textMuted,fontSize:13}}>Add an upcoming race in the Races tab first, then come back to generate a plan.</div>
              ) : (
                <div style={{display:"flex",flexDirection:"column",gap:12}}>
                  <div style={{fontSize:12,color:C.textMuted}}>Select your target race:</div>
                  <div style={{display:"flex",flexWrap:"wrap",gap:8}}>
                    {upcomingRaces.map(r => {
                      const sel = selectedRace?.id === r.id;
                      const weeksAway = Math.ceil((new Date(r.date) - new Date()) / (7*24*60*60*1000));
                      return (
                        <button key={r.id} onClick={() => setSelectedRace(sel ? null : r)} style={{
                          padding:"10px 16px",borderRadius:10,border:`1px solid ${sel ? C.cyan : C.border}`,
                          background: sel ? C.cyan+"15" : C.bg2, cursor:"pointer",textAlign:"left",
                          transition:"all 0.2s",
                        }}>
                          <div style={{fontSize:13,fontWeight:700,color: sel ? C.cyan : C.text}}>{r.name}</div>
                          <div style={{fontSize:11,color:C.textMuted,marginTop:2}}>{r.distance} — {r.date} ({weeksAway}w)</div>
                          {r.goalTime && <div style={{fontSize:11,color:C.amber,marginTop:2}}>Goal: {r.goalTime}</div>}
                        </button>
                      );
                    })}
                  </div>
                  {isOwner && <Btn onClick={() => generateWeek()} disabled={generatingWeek !== null || !selectedRace}>
                    {generatingWeek === "new" ? "Generating week 1..." : "Generate Week 1"}
                  </Btn>}
                </div>
              )}
            </Card>
          )}

          {/* Active plan */}
          {activePlan && activePlan.weeks && (
            <div style={{display:"flex",flexDirection:"column",gap:12}}>
              {/* Plan header */}
              <Card style={{borderColor:C.cyan+"40"}}>
                <div style={{display:"flex",justifyContent:"space-between",alignItems:"center",flexWrap:"wrap",gap:12}}>
                  <div>
                    <div style={{fontSize:16,fontWeight:800,color:C.text}}>{activePlan.raceName}</div>
                    <div style={{fontSize:12,color:C.textMuted,marginTop:2}}>
                      {activePlan.raceDistance} — {activePlan.raceDate}
                      {activePlan.goalTime ? ` — Goal: ${activePlan.goalTime}` : ""}
                    </div>
                    {/* Phase pills — same PlanMath.planStructure source as
                        the Roadmap / chart / Race readiness view. */}
                    {(() => {
                      const sp = window.PlanMath.planStructure(activePlan).phases;
                      if (sp.length <= 1) return null;
                      return (
                        <div style={{display:"flex",gap:6,marginTop:8,flexWrap:"wrap"}}>
                          {sp.map((p,i) => {
                            const phaseColors = { Conditioning: C.green, Build: C.amber, Peak: C.cyan };
                            const pc = phaseColors[p.name] || C.cyan;
                            return (
                              <span key={i} style={{fontSize:11,padding:"2px 9px",borderRadius:12,background:pc+"18",border:`1px solid ${pc}40`,color:pc,fontWeight:600}}>
                                {p.label} · {p.numWeeks}w
                              </span>
                            );
                          })}
                        </div>
                      );
                    })()}
                  </div>
                  {isOwner && <div style={{display:"flex",gap:8,flexWrap:"wrap",alignItems:"center"}}>
                    {/* "+ Add a Week" stays visible whenever the plan can
                        still grow up to the 36-week ceiling (matches the
                        backend's planWeeks cap). The original gate of
                        weeks.length < weeksTotal hid the button the moment
                        the plan reached its computed total — but plans
                        whose race day landed on the day after the last
                        week (e.g. 31 weeks ending Sat, race Sun) need one
                        more week to cover race day. The backend bumps
                        weeksTotal in Firestore when an append crosses the
                        old total, so the chart and progress bar update on
                        the same write. */}
                    {activePlan.weeks && activePlan.weeks.length < 36 && (
                      <Btn
                        onClick={() => generateWeek({ planId: activePlan.id })}
                        disabled={generatingWeek !== null || refreshingPlan !== null}
                        variant="secondary" small>
                        {generatingWeek === (activePlan.weeks.length + 1) ? "Generating..." : "+ Add a Week"}
                      </Btn>
                    )}
                    {activePlan.weeks && activePlan.weeks.length > 0 && (
                      <button
                        onClick={smartRefreshPlan}
                        disabled={smartRefreshing || generatingWeek !== null || refreshingPlan !== null}
                        title="One-pass plan rebalance — bumps peak volume +5–8mi/week and shortens the taper to 2 weeks to land race-day form (TSB) in the +15–25 target band. Instant, no AI. Completed/skipped/edited days kept."
                        style={{
                          background: smartRefreshing ? C.green + "20" : "none",
                          border: `1px solid ${smartRefreshing ? C.green : C.green + "60"}`,
                          borderRadius: 6, padding: "4px 10px",
                          color: C.green,
                          cursor: smartRefreshing ? "wait" : "pointer",
                          fontSize: 11, fontFamily: "'IBM Plex Mono',monospace",
                          display: "flex", alignItems: "center", gap: 5,
                        }}>
                        {smartRefreshing ? "⚡ Rebalancing…" : "⚡ Smart Plan Refresh"}
                      </button>
                    )}
                    {activePlan.weeks && activePlan.weeks.length > 0 && (
                      <button
                        onClick={() => refreshEntirePlan()}
                        disabled={smartRefreshing || generatingWeek !== null || refreshingPlan !== null}
                        title="Smart-refresh every week — keeps completed and skipped days, regenerates the rest with the latest rules."
                        style={{
                          background: refreshingPlan ? C.cyan + "20" : "none",
                          border: `1px solid ${refreshingPlan ? C.cyan : C.border}`,
                          borderRadius: 6, padding: "4px 10px",
                          color: refreshingPlan ? C.cyan : C.textMuted,
                          cursor: (generatingWeek || refreshingPlan) ? "wait" : "pointer",
                          fontSize: 11, fontFamily: "'IBM Plex Mono',monospace",
                          display: "flex", alignItems: "center", gap: 5,
                        }}>
                        {refreshingPlan
                          ? `↺ Refreshing week ${refreshingPlan.current} / ${refreshingPlan.total}…`
                          : "↺ Refresh Plan"}
                      </button>
                    )}
                    {activePlan.weeks && activePlan.weeks.length > 0 && (
                      <button
                        onClick={reviewPlan}
                        disabled={reviewingPlan}
                        title="AI coaching review of the whole plan vs. your race goal — confidence rating, gaps, injury-risk flags, and concrete adjustments."
                        style={{
                          background: reviewingPlan ? C.purple + "20" : "none",
                          border: `1px solid ${C.purple}40`,
                          borderRadius: 6, padding: "4px 10px",
                          color: C.purple, cursor: reviewingPlan ? "wait" : "pointer",
                          fontSize: 11, fontFamily: "'IBM Plex Mono',monospace",
                          display: "flex", alignItems: "center", gap: 5,
                        }}>
                        {reviewingPlan ? "🧠 Reviewing…" : "🧠 Review Plan"}
                      </button>
                    )}
                    <button
                      onClick={() => syncToGoogleCalendar(activePlan)}
                      disabled={syncingCalendar}
                      title="Sync all workouts to Google Calendar"
                      style={{background:"none",border:"1px solid #4285F430",borderRadius:6,padding:"4px 10px",color:"#4285F4",cursor:"pointer",fontSize:11,fontFamily:"'IBM Plex Mono',monospace",display:"flex",alignItems:"center",gap:5}}>
                      {syncingCalendar ? "Syncing…" : "📅 Sync to Google Calendar"}
                    </button>
                    <button
                      onClick={cleanGarminLibraryUI}
                      title="Delete chris.run-pushed workouts from Garmin's library that are no longer referenced by any plan day. Garmin has no bulk-delete UI — this cleans up duplicates accumulated from re-pushes."
                      style={{background:"none",border:`1px solid ${C.amber}40`,borderRadius:6,padding:"4px 10px",color:C.amber,cursor:"pointer",fontSize:11,fontFamily:"'IBM Plex Mono',monospace",display:"flex",alignItems:"center",gap:5}}>
                      ⌚ Clean Garmin Library
                    </button>
                    <button onClick={() => deletePlan(activePlan.id)} style={{
                      background:"none",border:`1px solid ${C.red}40`,borderRadius:6,padding:"4px 10px",
                      color:C.red,cursor:"pointer",fontSize:11,
                    }}>Delete</button>
                  </div>}
                </div>
                {/* Progress bar */}
                {(() => {
                  const st = window.PlanMath.planStructure(activePlan, getCurrentWeek(activePlan));
                  const cw = st.currentWeek || 1;
                  const total = st.weeksTotal;
                  return (
                <div style={{marginTop:12}}>
                  <div style={{display:"flex",justifyContent:"space-between",fontSize:11,color:C.textMuted,marginBottom:4}}>
                    <span>Week {cw} of {total}</span>
                    <span>{Math.round(cw / total * 100)}%</span>
                  </div>
                  <div style={{height:6,background:C.bg2,borderRadius:3,overflow:"hidden"}}>
                    <div style={{
                      height:"100%",borderRadius:3,
                      width:`${Math.min(100, cw / total * 100)}%`,
                      background:`linear-gradient(90deg, ${C.cyan}, ${C.green})`,
                      transition:"width 0.3s",
                    }} />
                  </div>
                </div>
                  );
                })()}
              </Card>

              {/* Plan Trajectory Review panel — coaching assessment of the
                  whole plan vs the race goal. Populated on-demand by the
                  "🧠 Review Plan" button in the header. Persists across
                  reloads via activePlan.review (cached on the plan doc).
                  Shows a stale indicator when any plan mutation has
                  happened since the cached review. */}
              {activePlan.review && (() => {
                const r = activePlan.review;
                const reviewedAtMs = activePlan.reviewedAt?.toMillis?.() ?? activePlan.reviewedAt ?? 0;
                const staleAtMs = activePlan.reviewStaleAt?.toMillis?.() ?? activePlan.reviewStaleAt ?? 0;
                const isStale = staleAtMs > reviewedAtMs;
                const bandColors = { green: C.green, yellow: C.amber, red: C.red };
                const band = bandColors[r.confidence] || C.cyan;
                const sevColor = (sev) => sev === "high" ? C.red : sev === "moderate" ? C.amber : C.textDim;
                const ageText = reviewedAtMs ? (() => {
                  const ageHr = (Date.now() - reviewedAtMs) / 3600000;
                  if (ageHr < 1) return `${Math.round(ageHr * 60)}m ago`;
                  if (ageHr < 48) return `${Math.round(ageHr)}h ago`;
                  return `${Math.round(ageHr / 24)}d ago`;
                })() : "";
                return (
                  <Card style={{ borderColor: band + "60", background: band + "08" }}>
                    <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 12, flexWrap: "wrap" }}>
                      <div style={{ display: "flex", alignItems: "center", gap: 12, flex: "1 1 auto", minWidth: 0 }}>
                        <span style={{
                          fontSize: 11, fontWeight: 800, color: band,
                          padding: "3px 10px", borderRadius: 12,
                          background: band + "20", border: `1px solid ${band}60`,
                          textTransform: "uppercase", letterSpacing: 0.6, whiteSpace: "nowrap",
                        }}>
                          {r.confidence || "—"} {typeof r.confidenceScore === "number" ? `· ${r.confidenceScore}/100` : ""}
                        </span>
                        <div style={{ fontSize: 13, fontWeight: 700, color: C.text, lineHeight: 1.35 }}>
                          {r.headline || "Plan review"}
                        </div>
                      </div>
                      <div style={{ display: "flex", gap: 8, alignItems: "center" }}>
                        {isStale && (
                          <span style={{ fontSize: 10, color: C.amber, padding: "2px 8px", border: `1px solid ${C.amber}40`, borderRadius: 10, whiteSpace: "nowrap" }} title="Plan has changed since this review was generated">
                            ⚠ stale
                          </span>
                        )}
                        {ageText && (
                          <span style={{ fontSize: 10, color: C.textMuted, whiteSpace: "nowrap" }}>{ageText}</span>
                        )}
                        <button
                          onClick={() => setReviewExpanded(v => !v)}
                          style={{ background: "none", border: `1px solid ${C.border}`, borderRadius: 6, padding: "3px 10px", color: C.textDim, cursor: "pointer", fontSize: 11, fontFamily: "'IBM Plex Mono',monospace" }}>
                          {reviewExpanded ? "↥ collapse" : "↧ details"}
                        </button>
                      </div>
                    </div>
                    {reviewExpanded && (
                      <div style={{ marginTop: 14, display: "flex", flexDirection: "column", gap: 14 }}>
                        {r.summary && (
                          <div style={{ fontSize: 12, color: C.textDim, lineHeight: 1.55 }}>{r.summary}</div>
                        )}
                        {r.trajectory && (
                          <div style={{ padding: "10px 12px", background: C.bg2, borderRadius: 8, border: `1px solid ${C.border}` }}>
                            <div style={{ fontSize: 10, color: C.textMuted, textTransform: "uppercase", letterSpacing: 0.5, marginBottom: 6, fontWeight: 700 }}>Trajectory</div>
                            <div style={{ display: "flex", gap: 14, flexWrap: "wrap", fontSize: 11, color: C.textDim, marginBottom: 6 }}>
                              {r.trajectory.currentPhase && <span><span style={{ color: C.textMuted }}>phase:</span> <b style={{ color: C.text }}>{r.trajectory.currentPhase}</b></span>}
                              {r.trajectory.weeksToRace != null && <span><span style={{ color: C.textMuted }}>to race:</span> <b style={{ color: C.text }}>{r.trajectory.weeksToRace}w</b></span>}
                              {r.trajectory.peakLongRunMi && <span><span style={{ color: C.textMuted }}>peak long:</span> <b style={{ color: C.text }}>{r.trajectory.peakLongRunMi}mi (W{r.trajectory.peakLongRunWeek})</b></span>}
                              {r.trajectory.peakWeeklyMi && <span><span style={{ color: C.textMuted }}>peak week:</span> <b style={{ color: C.text }}>{r.trajectory.peakWeeklyMi}mi (W{r.trajectory.peakWeeklyWeek})</b></span>}
                            </div>
                            {r.trajectory.reasoning && (
                              <div style={{ fontSize: 12, color: C.textDim, lineHeight: 1.55 }}>{r.trajectory.reasoning}</div>
                            )}
                          </div>
                        )}
                        {Array.isArray(r.gaps) && r.gaps.length > 0 && (
                          <div>
                            <div style={{ fontSize: 10, color: C.textMuted, textTransform: "uppercase", letterSpacing: 0.5, marginBottom: 6, fontWeight: 700 }}>Gaps ({r.gaps.length})</div>
                            <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
                              {r.gaps.map((g, i) => (
                                <div key={i} style={{ padding: "8px 10px", background: C.bg2, borderRadius: 6, border: `1px solid ${sevColor(g.severity)}30`, borderLeft: `3px solid ${sevColor(g.severity)}` }}>
                                  <div style={{ display: "flex", justifyContent: "space-between", gap: 8, marginBottom: 4 }}>
                                    <div style={{ fontSize: 12, fontWeight: 700, color: C.text }}>{g.category}</div>
                                    <span style={{ fontSize: 9, color: sevColor(g.severity), textTransform: "uppercase", letterSpacing: 0.6 }}>{g.severity}</span>
                                  </div>
                                  <div style={{ fontSize: 11, color: C.textDim, lineHeight: 1.5, marginBottom: 4 }}>{g.finding}</div>
                                  <div style={{ fontSize: 11, color: C.cyan, lineHeight: 1.5 }}>→ {g.recommendation}</div>
                                </div>
                              ))}
                            </div>
                          </div>
                        )}
                        {Array.isArray(r.injuryRisks) && r.injuryRisks.length > 0 && (
                          <div>
                            <div style={{ fontSize: 10, color: C.textMuted, textTransform: "uppercase", letterSpacing: 0.5, marginBottom: 6, fontWeight: 700 }}>Injury risks ({r.injuryRisks.length})</div>
                            <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
                              {r.injuryRisks.map((g, i) => (
                                <div key={i} style={{ padding: "8px 10px", background: C.bg2, borderRadius: 6, border: `1px solid ${sevColor(g.severity)}30`, borderLeft: `3px solid ${sevColor(g.severity)}` }}>
                                  <div style={{ display: "flex", justifyContent: "space-between", gap: 8, marginBottom: 4 }}>
                                    <div style={{ fontSize: 12, fontWeight: 700, color: C.text }}>{g.category}</div>
                                    <span style={{ fontSize: 9, color: sevColor(g.severity), textTransform: "uppercase", letterSpacing: 0.6 }}>{g.severity}</span>
                                  </div>
                                  <div style={{ fontSize: 11, color: C.textDim, lineHeight: 1.5, marginBottom: 4 }}>{g.finding}</div>
                                  <div style={{ fontSize: 11, color: C.cyan, lineHeight: 1.5 }}>→ {g.recommendation}</div>
                                </div>
                              ))}
                            </div>
                          </div>
                        )}
                        {Array.isArray(r.adjustments) && r.adjustments.length > 0 && (
                          <div>
                            <div style={{ fontSize: 10, color: C.textMuted, textTransform: "uppercase", letterSpacing: 0.5, marginBottom: 6, fontWeight: 700 }}>Suggested adjustments ({r.adjustments.length})</div>
                            <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
                              {r.adjustments.map((a, i) => (
                                <div key={i} style={{ padding: "8px 10px", background: C.bg2, borderRadius: 6, border: `1px solid ${C.border}`, display: "flex", gap: 10, alignItems: "flex-start" }}>
                                  <span style={{ fontSize: 10, color: C.amber, fontWeight: 700, fontFamily: "'IBM Plex Mono',monospace", minWidth: 60 }}>
                                    W{a.week}{a.day ? ` · ${a.day.slice(0, 3)}` : ""}
                                  </span>
                                  <div style={{ flex: 1, minWidth: 0 }}>
                                    <div style={{ fontSize: 12, color: C.text, lineHeight: 1.45 }}>{a.change}</div>
                                    {a.rationale && (
                                      <div style={{ fontSize: 11, color: C.textMuted, lineHeight: 1.45, marginTop: 2 }}>{a.rationale}</div>
                                    )}
                                  </div>
                                </div>
                              ))}
                            </div>
                          </div>
                        )}
                        {r.nextFourWeeks && (
                          <div style={{ padding: "10px 12px", background: C.cyan + "08", borderRadius: 8, border: `1px solid ${C.cyan}30` }}>
                            <div style={{ fontSize: 10, color: C.cyan, textTransform: "uppercase", letterSpacing: 0.5, marginBottom: 6, fontWeight: 700 }}>Next 4 weeks — priorities</div>
                            <div style={{ fontSize: 12, color: C.textDim, lineHeight: 1.55 }}>{r.nextFourWeeks}</div>
                          </div>
                        )}
                      </div>
                    )}
                  </Card>
                );
              })()}

              {/* Mileage chart: planned vs actual + periodized projection */}
              {(() => {
                const planStruct = window.PlanMath.planStructure(activePlan);
                const weeksTotal = planStruct.weeksTotal;
                const currentWeek = getCurrentWeek(activePlan);
                const chartData = [];

                // 1) Fill in generated weeks (planned + actual).
                //
                // The PLANNED bar always uses the week's prescribed
                // distance (PlanMath.plannedMiForWeek), matching the
                // weekly card's plannedFromDistance. The ACTUAL bar
                // uses getAdjustedMiForWeek (Strava-merged actuals).
                // Showing both for completed weeks is the whole point
                // of the chart — plan vs. reality — so even on past
                // weeks the bars are independent heights.
                //
                // PlanMath.plannedMiForWeek is the single source of
                // truth shared with Plan Compliance and the per-week
                // card, so all three views agree (an earlier ad-hoc
                // daySum with a dedupe-by-day-name pass under-counted
                // against the card on weeks with duplicate-day edits).
                for (let i = 0; i < weeksTotal; i++) {
                  const week = activePlan.weeks[i];
                  // Single source of truth — same helper as Plan
                  // Compliance and the per-week card. Previously this
                  // chart had its own daySum reducer with a speculative
                  // dedupe-by-day-name pass that under-counted vs. the
                  // card; switching to the shared helper makes all
                  // three views agree.
                  const plannedRaw = week ? window.PlanMath.plannedMiForWeek(week) : null;
                  const actualRaw = week ? getAdjustedMiForWeek(activePlan, i + 1) : 0;
                  const isPast = i + 1 < currentWeek;
                  const isCurrent = i + 1 === currentWeek;
                  // Show the ORIGINAL prescription for every generated
                  // week — past, current, future — so a week where the
                  // athlete over- or under-shot the target reads as two
                  // distinct bar heights instead of collapsing into one.
                  // (Earlier we forced past-week target=actual to avoid
                  // "chart noise" on completed weeks, but that hides the
                  // signal the chart is meant to surface — plan vs. reality.)
                  const round05 = (v) => v == null ? null : Math.round(v * 2) / 2;
                  chartData.push({
                    name: `W${i + 1}`,
                    week: i + 1,
                    planned: round05(plannedRaw || null),
                    actual: round05((isPast || isCurrent) && actualRaw > 0 ? actualRaw : null),
                    projected: null,
                    phase: week?.phase || null,
                  });
                }

                // 2) Build periodized projection for ungenerated weeks
                // Determine phase boundaries (cumulative week ranges)
                const phaseBounds = planStruct.phases.map(p => ({ name: p.name, start: p.startWeek - 1, end: p.endWeek - 1 }));

                // Get starting mileage from last generated week
                const generatedWeeks = chartData.filter(d => d.planned !== null && d.planned > 0);
                const startMiles = generatedWeeks.length > 0 ? generatedWeeks[generatedWeeks.length - 1].planned : 15;
                const lastGenIdx = generatedWeeks.length > 0 ? chartData.indexOf(generatedWeeks[generatedWeeks.length - 1]) : -1;

                // Absolute mile targets for each phase endpoint
                // Conditioning: ramp volume from current → ~38 MPW
                // Build: 45 → 50 MPW (steps up +3 from Conditioning peak)
                // Peak: true 16-week marathon plan, peak at 62 MPW, 4-5 weeks over 55
                const CONDITIONING_END = 42;
                const BUILD_START = 45;
                const BUILD_END = 50;
                const PEAK_MILE = 62;

                // 16-week peak template — +2mi/week ramp to peak, hold,
                // then 3-week taper. Mirrors the canonical array in
                // functions/index.js and the stale-template badge (PEAK16
                // below) so chart projections, the badge, and the backend
                // generator all agree on what each Peak week should hit.
                // Per athlete preference: no mid-cycle down weeks and a
                // ≥2mi/week climb — self-regulation via skip / easy days
                // replaces formal down weeks.
                const peak16 = [50, 52, 54, 56, 58, 60, 62, 62, 62, 62, 62, 62, 60, 45, 32, 26];

                // For each ungenerated week, calculate projected miles
                for (let i = 0; i < chartData.length; i++) {
                  if (chartData[i].planned !== null) continue; // skip generated weeks

                  // Which phase is this week in?
                  const pb = phaseBounds.find(p => i >= p.start && i <= p.end);
                  const phaseName = pb?.name || "Peak";
                  const phaseStart = pb?.start || 0;
                  const phaseEnd = pb?.end || weeksTotal - 1;
                  const phaseLen = phaseEnd - phaseStart + 1;
                  const weekInPhase = i - phaseStart;
                  const phasePct = phaseLen > 1 ? weekInPhase / (phaseLen - 1) : 1;

                  let miles;

                  if (phaseName === "Peak" || phaseName === "Peak Training") {
                    // Use the 16-week plan template, scaled to actual phase length
                    const scaledIdx = Math.min(Math.round(weekInPhase * (peak16.length - 1) / Math.max(phaseLen - 1, 1)), peak16.length - 1);
                    miles = peak16[scaledIdx];
                  } else if (phaseName === "Build" || phaseName === "Race Build") {
                    // Ramp from BUILD_START (45, +3 step from CONDITIONING_END)
                    // to BUILD_END (50), at least +2mi/week (athlete
                    // preference — matches functions/index.js). No mid-cycle
                    // down weeks, so the projection stays monotonic like the
                    // backend generator.
                    const proportional = BUILD_START + (BUILD_END - BUILD_START) * phasePct;
                    const stepped = BUILD_START + 2 * weekInPhase;
                    miles = Math.min(BUILD_END, Math.max(proportional, stepped));
                  } else {
                    // Conditioning: steady volume ramp from start to
                    // conditioning end, at least +2mi/week, no down weeks.
                    const proportional = startMiles + (CONDITIONING_END - startMiles) * phasePct;
                    const stepped = startMiles + 2 * weekInPhase;
                    miles = Math.min(CONDITIONING_END, Math.max(proportional, stepped));
                  }

                  chartData[i].projected = Math.round(miles * 2) / 2;
                  chartData[i].phase = phaseName;
                }

                // Connect projection line from last generated week
                if (lastGenIdx >= 0 && lastGenIdx < chartData.length - 1) {
                  chartData[lastGenIdx].projected = chartData[lastGenIdx].planned;
                }

                // Phase color bands for background. Re-attach the phase
                // to every chart entry from phaseBounds (the plan-level
                // phases array, source of truth) so per-bar coloring is
                // consistent with the summary card and isn't subject to
                // whatever Claude tagged on individual weeks.
                const phaseColors = { Conditioning: C.green, Build: C.amber, Peak: C.cyan };
                chartData.forEach((d, idx) => {
                  const pb = phaseBounds.find(p => idx >= p.start && idx <= p.end);
                  d.phaseFromBounds = pb?.name || null;
                });
                const colorForIdx = (idx) => {
                  const pb = phaseBounds.find(p => idx >= p.start && idx <= p.end);
                  return phaseColors[pb?.name] || C.cyan;
                };

                return chartData.length > 0 && (
                  <Card>
                    <div style={{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:8}}>
                      <div style={{fontSize:13,fontWeight:700,color:C.text}}>Weekly Mileage</div>
                      <div style={{display:"flex",gap:12,fontSize:10,color:C.textMuted,flexWrap:"wrap"}}>
                        <span><span style={{display:"inline-block",width:8,height:8,borderRadius:2,background:C.green+"70",marginRight:4}} />Base</span>
                        <span><span style={{display:"inline-block",width:8,height:8,borderRadius:2,background:C.amber+"70",marginRight:4}} />Build</span>
                        <span><span style={{display:"inline-block",width:8,height:8,borderRadius:2,background:C.cyan+"70",marginRight:4}} />Peak</span>
                        <span><span style={{display:"inline-block",width:8,height:8,borderRadius:2,background:C.text+"40",border:`1px solid ${C.text}`,marginRight:4}} />Actual</span>
                        <span><span style={{display:"inline-block",width:16,height:0,borderTop:`2px dashed ${C.amber}`,marginRight:4,verticalAlign:"middle"}} />Projected</span>
                      </div>
                    </div>
                    <ResponsiveContainer width="100%" height={200}>
                      <ComposedChart data={chartData} margin={{top:5,right:10,left:-10,bottom:5}}>
                        <CartesianGrid strokeDasharray="3 3" stroke={C.border+"30"} />
                        <XAxis dataKey="name" tick={{fontSize:9,fill:C.textMuted}} interval={weeksTotal > 20 ? 1 : 0} />
                        <YAxis tick={{fontSize:10,fill:C.textMuted}} />
                        <Tooltip
                          contentStyle={{background:C.bg2,border:`1px solid ${C.border}`,borderRadius:8,fontSize:11,color:C.text}}
                          itemStyle={{color:C.text}}
                          labelStyle={{color:C.text,fontWeight:700,marginBottom:4}}
                          /* Recharts inherits per-item color from the series
                             fill — for the planned bars that's a low-alpha
                             phase tint that reads as muted grey on the dark
                             tooltip bg. Force the value text to C.text via
                             a styled span returned from the formatter so
                             every row is high-contrast regardless of the
                             bar's fill alpha. */
                          formatter={(val, name, props) => {
                            const suffix = props.payload.phaseFromBounds ? ` (${props.payload.phaseFromBounds})` : "";
                            const styled = (txt) => React.createElement("span", { style: { color: C.text } }, txt);
                            return [styled(val ? `${val} mi` : "—"), styled(name + suffix)];
                          }}
                        />
                        {/* Phase background bands */}
                        {phaseBounds.map((pb, pi) => {
                          const pc = phaseColors[pb.name] || C.cyan;
                          return (
                            <ReferenceLine key={pi} x={`W${pb.start + 1}`} stroke={pc} strokeWidth={0} label={{value: pb.name?.charAt(0), position:"top", fontSize:8, fill:pc+"80"}} />
                          );
                        })}
                        <Bar dataKey="planned" radius={[3,3,0,0]} name="Planned">
                          {chartData.map((_, idx) => {
                            const c = colorForIdx(idx);
                            return <Cell key={idx} fill={c+"50"} stroke={c} strokeWidth={1} />;
                          })}
                        </Bar>
                        <Bar dataKey="actual" radius={[3,3,0,0]} name="Actual" stroke={C.text} strokeWidth={1}>
                          {chartData.map((_, idx) => {
                            const c = colorForIdx(idx);
                            // Actual bars share the phase hue but use a
                            // darker overlay so they read as a stronger
                            // emphasis on top of the planned bar.
                            return <Cell key={idx} fill={c+"AA"} />;
                          })}
                        </Bar>
                        <Line dataKey="projected" stroke={C.amber} strokeWidth={2} strokeDasharray="6 3" dot={false} name="Projected" connectNulls={false} />
                      </ComposedChart>
                    </ResponsiveContainer>
                  </Card>
                );
              })()}

              {/* Weekly breakdown */}
              {/* Expand-all / collapse-all toggles for the weeks list.
                  Useful when scanning many weeks at once or when the
                  athlete wants to focus only on the current week. */}
              {activePlan?.weeks?.length > 0 && (() => {
                const numWeeks = activePlan.weeks.length;
                const allExpanded = expandedWeeks.size === numWeeks && collapsedWeeks.size === 0;
                const allCollapsed = collapsedWeeks.size === numWeeks && expandedWeeks.size === 0;
                return (
                  <div style={{ display: "flex", gap: 8, marginBottom: 8, alignItems: "center", justifyContent: "flex-end" }}>
                    <span style={{ fontSize: 10, color: C.textMuted }}>Weeks:</span>
                    <button onClick={() => expandAllWeeks(numWeeks)} disabled={allExpanded}
                      style={{
                        padding: "3px 10px", fontSize: 10, borderRadius: 6, fontFamily: "'IBM Plex Mono',monospace",
                        background: allExpanded ? "transparent" : C.bg2,
                        color: allExpanded ? C.textMuted : C.textDim,
                        border: `1px solid ${C.border}`, cursor: allExpanded ? "default" : "pointer",
                      }}>↧ expand all</button>
                    <button onClick={() => collapseAllWeeks(numWeeks)} disabled={allCollapsed}
                      style={{
                        padding: "3px 10px", fontSize: 10, borderRadius: 6, fontFamily: "'IBM Plex Mono',monospace",
                        background: allCollapsed ? "transparent" : C.bg2,
                        color: allCollapsed ? C.textMuted : C.textDim,
                        border: `1px solid ${C.border}`, cursor: allCollapsed ? "default" : "pointer",
                      }}>↥ collapse all</button>
                  </div>
                );
              })()}
              {/* Plan-level phase boundaries are the source of truth for
                 the phase headers — week.phase is whatever Claude tagged
                 on the individual week and can drift from the plan summary
                 (e.g. Claude tagging week 5 as "Build" while phases say
                 Base ends at week 7). Always compute the displayed phase
                 from wi against activePlan.phases. */}
              {(() => {
                const psPhases = window.PlanMath.planStructure(activePlan).phases;
                // wi is 0-based; return the phase containing that week, or
                // null past the last phase boundary (no trailing fallback —
                // the stale-template check below must not fire on weeks the
                // phase list doesn't cover).
                const phaseAtWeek = (wi) => psPhases.find(p => (wi + 1) >= p.startWeek && (wi + 1) <= p.endWeek) || null;
                // Current peak16 template — same shape as functions/index.js.
                // If a stored week's targetMiles drifts more than ~15% below
                // its expected position on this template, surface a "⚠️ stale
                // template" badge so an old-template artifact (e.g. a stale
                // mid-Peak dip from before PR #137) is visible at a glance
                // instead of looking like a real taper. Re-running Smart
                // Refresh on that week regenerates against the current
                // template.
                const PEAK16 = [50, 52, 54, 56, 58, 60, 62, 62, 62, 62, 62, 62, 60, 45, 32, 26];
                const phaseStartWeek = (phaseName) => {
                  const p = psPhases.find(ph => ph.name === phaseName);
                  return p ? p.startWeek : null;
                };
                const expectedTemplateMi = (wi) => {
                  const p = phaseAtWeek(wi);
                  if (!p) return null;
                  if (p.name !== "Peak" && p.name !== "Peak Training") return null;
                  const start = phaseStartWeek(p.name);
                  if (!start) return null;
                  const weekInPhase = (wi + 1) - start;
                  if (weekInPhase < 0 || weekInPhase >= p.numWeeks) return null;
                  const scaledIdx = Math.min(
                    Math.round(weekInPhase * (PEAK16.length - 1) / Math.max(p.numWeeks - 1, 1)),
                    PEAK16.length - 1
                  );
                  return PEAK16[scaledIdx];
                };
                return activePlan.weeks.map((week, wi) => {
                const currentWeek = getCurrentWeek(activePlan);
                const isCurrent = wi + 1 === currentWeek;
                const phaseColors = { Conditioning: C.green, Build: C.amber, Peak: C.cyan };
                const planPhase = phaseAtWeek(wi);
                const prevPlanPhase = wi === 0 ? null : phaseAtWeek(wi - 1);
                const isPhaseStart = wi === 0 || planPhase?.name !== prevPlanPhase?.name;
                const phaseLabel = planPhase?.label || planPhase?.name || week.phase;
                const isPast = wi + 1 < currentWeek;
                const isExpanded = isWeekExpanded(wi, isCurrent);
                const completion = getCompletionForWeek(activePlan, wi + 1);
                // PLANNED = sum of distanceMi across run days (includes
                // skipped days — skipping means "didn't run this," not
                // "wasn't prescribed"). Same helper as Plan Compliance
                // and Weekly Mileage charts so all three views agree.
                const plannedMi = window.PlanMath.plannedMiForWeek(week);
                const crossMi = (week.days || []).filter(d => d.type === "cross_train").reduce((s,d) => s + (d.distanceMi || 0), 0);
                // Credit linked runs to their planned week regardless of actual date.
                const actualMi = getAdjustedMiForWeek(activePlan, wi + 1);
                const pct = plannedMi > 0 ? Math.round(actualMi / plannedMi * 100) : 0;

                const pc = phaseColors[planPhase?.name] || phaseColors[week.phase] || C.cyan;
                return (
                  <React.Fragment key={wi}>
                  {isPhaseStart && planPhase && (
                    <div style={{display:"flex",alignItems:"center",gap:10,padding:"4px 0"}}>
                      <div style={{flex:1,height:1,background:`linear-gradient(90deg, ${pc}60, transparent)`}} />
                      <span style={{fontSize:11,fontWeight:700,color:pc,padding:"3px 12px",borderRadius:12,background:pc+"15",border:`1px solid ${pc}40`,letterSpacing:"0.05em",textTransform:"uppercase"}}>
                        {phaseLabel}
                      </span>
                      <div style={{flex:1,height:1,background:`linear-gradient(270deg, ${pc}60, transparent)`}} />
                    </div>
                  )}
                  <Card style={{
                    borderColor: isCurrent ? C.cyan+"60" : isPast ? C.green+"30" : C.border,
                    opacity: isPast ? 0.7 : 1,
                  }}>
                    {/* Week header — clickable. Whole row toggles
                        expand/collapse including for the current
                        week. Hover hint via background change. */}
                    <div onClick={() => toggleWeekExpand(wi, isCurrent)} style={{
                      display:"flex",justifyContent:"space-between",alignItems:"center",cursor:"pointer",
                      padding: "4px 6px", margin: "-4px -6px", borderRadius: 6,
                      transition: "background 0.15s",
                    }} onMouseEnter={e => e.currentTarget.style.background = C.bg2 + "40"}
                       onMouseLeave={e => e.currentTarget.style.background = "transparent"}>
                      <div style={{display:"flex",alignItems:"center",gap:10}}>
                        {isCurrent && <span style={{width:8,height:8,borderRadius:"50%",background:C.cyan,display:"inline-block"}} />}
                        <div>
                          <span style={{fontSize:13,fontWeight:700,color: isCurrent ? C.cyan : C.text}}>
                            Week {week.week || wi + 1}
                          </span>
                          {(() => {
                            // Mon-Sun aligned date range: Week 1 starts
                            // the Monday on or after createdAt; race day
                            // (Sunday) lands as the LAST day of the
                            // final week. UK/EU calendar convention.
                            const w1 = planWeek1Start(activePlan?.createdAt);
                            if (!w1) return null;
                            const start = new Date(w1.getTime() + wi * 7 * 86400000);
                            const end = new Date(start.getTime() + 6 * 86400000);
                            const fmt = d => d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
                            return (
                              <span style={{fontSize:11,color:C.textMuted,marginLeft:8,fontFamily:"'IBM Plex Mono',monospace"}}>
                                {fmt(start)} – {fmt(end)}
                              </span>
                            );
                          })()}
                          <span style={{fontSize:12,color:C.textMuted,marginLeft:8}}>{week.theme}</span>
                        </div>
                      </div>
                      <div style={{display:"flex",alignItems:"center",gap:8}}>
                        {(() => {
                          const exp = expectedTemplateMi(wi);
                          if (!exp) return null;
                          // Skip taper weeks (last 3) — those are intentionally low.
                          const weeksFromRace = (activePlan.weeksTotal || activePlan.weeks.length) - (wi + 1);
                          if (weeksFromRace < 3) return null;
                          const stale = plannedMi > 0 && plannedMi < exp * 0.85;
                          if (!stale) return null;
                          return (
                            <span
                              style={{fontSize:10,color:C.amber,fontWeight:600,padding:"2px 6px",border:`1px solid ${C.amber}40`,borderRadius:4,whiteSpace:"nowrap"}}
                              title={`Stale template: stored ${plannedMi.toFixed(0)}mi, current peak16 template suggests ~${exp}mi for this position. Smart-Refresh this week to regenerate against the current template.`}>
                              ⚠ stale ~{exp}mi
                            </span>
                          );
                        })()}
                        {(isPast || isCurrent) && actualMi > 0 && (
                          <span style={{position:"relative",display:"inline-flex",alignItems:"center",gap:4}}>
                            <span style={{fontSize:11,color: pct >= 80 ? C.green : pct >= 50 ? C.amber : C.red,fontWeight:600}}>
                              {actualMi.toFixed(1)}/{plannedMi.toFixed(0)} mi
                            </span>
                            <button
                              onClick={(e) => { e.stopPropagation(); setAttributionWeek(attributionWeek === wi ? null : wi); }}
                              title="Show which activities make up this total"
                              style={{background:"none",border:"none",padding:"0 2px",cursor:"pointer",color:attributionWeek === wi ? C.cyan : C.textMuted,fontSize:11,lineHeight:1}}>ⓘ</button>
                            {attributionWeek === wi && (() => {
                              const attr = getWeekAttribution(activePlan, wi + 1, activities);
                              return (
                                <div onClick={(e) => e.stopPropagation()}
                                  style={{position:"absolute",top:"100%",right:0,marginTop:6,background:C.bg2,border:`1px solid ${C.border}`,borderRadius:8,boxShadow:"0 6px 18px rgba(0,0,0,0.4)",padding:"10px 12px",zIndex:300,minWidth:300,maxWidth:380,fontSize:11,textAlign:"left",cursor:"default",whiteSpace:"normal"}}>
                                  <div style={{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:8}}>
                                    <span style={{fontWeight:700,color:C.text,fontSize:12}}>W{wi+1} mileage breakdown</span>
                                    <button onClick={(e) => { e.stopPropagation(); setAttributionWeek(null); }}
                                      style={{background:"none",border:"none",color:C.textMuted,cursor:"pointer",fontSize:14,lineHeight:1,padding:0}}>×</button>
                                  </div>
                                  {attr.entries.length === 0 ? (
                                    <div style={{color:C.textMuted}}>No runs found for this week.</div>
                                  ) : (
                                    <>
                                      {attr.entries.map((e, i) => (
                                        <div key={(e.id || "?") + "-" + e.dateLabel + "-" + i} style={{padding:"4px 0",borderTop: i>0?`1px solid ${C.border}40`:"none"}}>
                                          <div style={{display:"flex",justifyContent:"space-between",alignItems:"baseline",gap:8}}>
                                            <span style={{color: e.credited ? C.text : C.textMuted, textDecoration: e.credited ? "none" : "line-through", fontWeight:600}}>
                                              {e.dateLabel} · {e.mi.toFixed(1)} mi
                                            </span>
                                            <span style={{color: e.credited ? C.green : C.textMuted,fontSize:10,fontWeight:600,whiteSpace:"nowrap"}}>
                                              {e.credited ? `+${e.mi.toFixed(1)}` : "—"}
                                            </span>
                                          </div>
                                          <div style={{color:C.textMuted,fontSize:10,lineHeight:1.4,marginTop:2}}>{e.reason}</div>
                                        </div>
                                      ))}
                                      <div style={{marginTop:8,paddingTop:6,borderTop:`1px solid ${C.border}`,display:"flex",justifyContent:"space-between",fontWeight:700,fontSize:11,color:C.text}}>
                                        <span>Total credited</span>
                                        <span>{attr.totalMi.toFixed(1)} mi</span>
                                      </div>
                                    </>
                                  )}
                                </div>
                              );
                            })()}
                          </span>
                        )}
                        {!(isPast || isCurrent) || actualMi === 0 ? (
                          <span style={{fontSize:12,color:C.textMuted,fontWeight:600}}>
                            {plannedMi.toFixed(0)} mi
                          </span>
                        ) : null}
                        {crossMi > 0 && (
                          <span style={{fontSize:10,color:C.textMuted,fontWeight:400}} title="Cross-training miles (not counted in running total)">
                            + {crossMi.toFixed(0)} XT
                          </span>
                        )}
                        {isOwner && !isPast && (
                          <>
                            <button
                              onClick={(e) => { e.stopPropagation(); copyWeekAsText(week, wi); }}
                              title="Copy this week's plan as plain text — useful for sharing or pasting into a coaching review."
                              style={{
                                background: copiedWeek === wi ? C.green + "20" : "none",
                                border: `1px solid ${copiedWeek === wi ? C.green : C.border}`,
                                borderRadius: 4, padding: "2px 8px", cursor: "pointer",
                                fontSize: 10, color: copiedWeek === wi ? C.green : C.textMuted,
                                fontFamily: "'IBM Plex Mono',monospace", whiteSpace: "nowrap",
                              }}>
                              {copiedWeek === wi ? "✓ Copied" : "📋 Copy"}
                            </button>
                            <button
                              onClick={(e) => { e.stopPropagation(); pushWeekViaBrowser(wi); }}
                              disabled={pushingWeek !== null}
                              title="Push via your logged-in Garmin browser session (recommended — no MFA, no rate limits). Needs the chris.run userscript installed."
                              style={{
                                background: pushingWeek === wi ? C.green + "20" : "none",
                                border: `1px solid ${pushingWeek === wi ? C.green : C.border}`,
                                borderRadius: 4, padding: "2px 8px", cursor: pushingWeek !== null ? "wait" : "pointer",
                                fontSize: 10, color: pushingWeek === wi ? C.green : C.textMuted,
                                fontFamily: "'IBM Plex Mono',monospace", whiteSpace: "nowrap",
                              }}>
                              {pushingWeek === wi ? "⌚ staging…" : "⌚ Push Week"}
                            </button>
                            <button
                              onClick={(e) => { e.stopPropagation(); pushWeekToGarmin(wi); }}
                              disabled={pushingWeek !== null}
                              title="Fallback: push via server-side OAuth. Requires a valid OAuth session in Settings → Garmin Connect (breaks on rate limits)."
                              style={{
                                background: "none",
                                border: `1px solid ${C.border}`,
                                borderRadius: 4, padding: "2px 6px", cursor: pushingWeek !== null ? "wait" : "pointer",
                                fontSize: 10, color: C.textMuted,
                                fontFamily: "'IBM Plex Mono',monospace", whiteSpace: "nowrap",
                              }}>
                              OAuth
                            </button>
                          </>
                        )}
                        <div style={{position:"relative"}}>
                          <button
                            onClick={(e) => { e.stopPropagation(); setRefreshMenuWeek(refreshMenuWeek === wi ? null : wi); }}
                            disabled={generatingWeek !== null}
                            title="Refresh options for this week"
                            style={{background:"none",border:`1px solid ${C.border}`,borderRadius:4,padding:"2px 6px",cursor:"pointer",fontSize:10,color:C.textMuted,display:"flex",alignItems:"center",gap:2}}>
                            {generatingWeek === wi + 1 ? "…" : <><span>↺</span><span style={{fontSize:8}}>▾</span></>}
                          </button>
                          {refreshMenuWeek === wi && generatingWeek !== wi + 1 && (
                            <div style={{position:"absolute",right:0,top:"calc(100% + 4px)",background:C.bg2,border:`1px solid ${C.border}`,borderRadius:8,zIndex:200,minWidth:168,boxShadow:"0 4px 16px rgba(0,0,0,0.35)",overflow:"hidden"}}
                                 onClick={e => e.stopPropagation()}>
                              <button
                                onClick={() => { setRefreshMenuWeek(null); generateWeek({ planId: activePlan.id, weekNum: wi + 1, replace: true }); }}
                                style={{display:"block",width:"100%",textAlign:"left",padding:"9px 12px",background:"none",border:"none",borderBottom:`1px solid ${C.border}`,cursor:"pointer"}}>
                                <div style={{fontSize:12,color:C.text,fontWeight:600}}><span style={{color:C.cyan}}>↺</span> Smart Refresh</div>
                                <div style={{fontSize:10,color:C.textMuted,marginTop:2}}>Keep completed/skipped days</div>
                              </button>
                              <button
                                onClick={() => {
                                  setRefreshMenuWeek(null);
                                  if (confirm(`Regenerate ALL 7 days in Week ${wi + 1}?\n\nCompleted and skipped days will also be replaced.\nThis cannot be undone.`)) {
                                    generateWeek({ planId: activePlan.id, weekNum: wi + 1, replace: true, forceComplete: true });
                                  }
                                }}
                                style={{display:"block",width:"100%",textAlign:"left",padding:"9px 12px",background:"none",border:"none",cursor:"pointer"}}>
                                <div style={{fontSize:12,color:C.text,fontWeight:600}}><span style={{color:C.amber}}>⟳</span> Complete Refresh</div>
                                <div style={{fontSize:10,color:C.textMuted,marginTop:2}}>Regenerate all 7 days fresh</div>
                              </button>
                            </div>
                          )}
                        </div>
                        {isOwner && (
                          <button
                            onClick={(e) => { e.stopPropagation(); syncToGoogleCalendar(activePlan, wi + 1); }}
                            disabled={syncingCalendar}
                            title="Add this week's workouts to Google Calendar"
                            style={{background:"none",border:`1px solid ${C.border}`,borderRadius:4,padding:"2px 6px",cursor:"pointer",fontSize:10,color:"#4285F4",whiteSpace:"nowrap"}}>
                            {syncingCalendar ? "…" : "📅"}
                          </button>
                        )}
                        {/* Bigger, higher-contrast chevron — clearer
                            affordance that the row collapses. */}
                        <span style={{
                          fontSize: 18,
                          color: isExpanded ? C.cyan : C.textDim,
                          transform: isExpanded ? "rotate(180deg)" : "",
                          transition: "transform 0.2s, color 0.2s",
                          lineHeight: 1, padding: "0 4px",
                        }}>▾</span>
                      </div>
                    </div>

                    {/* Expanded: daily workouts */}
                    {isExpanded && (
                      <div style={{marginTop:12,display:"flex",flexDirection:"column",gap:8}}>
                        {/* Week insights — rich structured brief */}
                        {(week.coachNotes || week.weekInsights) && (() => {
                          const ins = week.weekInsights;
                          return (
                            <div style={{background:C.bg2,border:`1px solid ${C.border}`,borderRadius:10,overflow:"hidden"}}>
                              {/* Coach header note */}
                              {week.coachNotes && (
                                <div style={{padding:"10px 14px",borderBottom: ins ? `1px solid ${C.border}40` : "none",display:"flex",gap:10,alignItems:"flex-start"}}>
                                  <span style={{fontSize:15,flexShrink:0}}>🗒️</span>
                                  <div style={{fontSize:12,color:C.textDim,lineHeight:1.6,fontStyle:"italic"}}>{week.coachNotes}</div>
                                </div>
                              )}
                              {/* Structured insights grid */}
                              {ins && (
                                <div style={{display:"grid",gridTemplateColumns:"1fr 1fr",gap:0}}>
                                  {[
                                    { icon:"🎯", label:"WEEK FOCUS", text: ins.focus, color: C.cyan },
                                    { icon:"💪", label:"KEY SESSIONS", text: ins.keySessions, color: C.purple },
                                    { icon:"👁️", label:"ATHLETE CUE", text: ins.athleteCue, color: C.amber },
                                    { icon:"📅", label:"COMING UP", text: ins.lookAhead, color: C.green },
                                  ].map(({ icon, label, text, color }) => text ? (
                                    <div key={label} style={{padding:"10px 14px",borderRight:`1px solid ${C.border}30`,borderBottom:`1px solid ${C.border}30`}}>
                                      <div style={{fontSize:9,fontWeight:700,color,letterSpacing:"0.08em",marginBottom:4,display:"flex",alignItems:"center",gap:5}}>
                                        <span>{icon}</span>{label}
                                      </div>
                                      <div style={{fontSize:11,color:C.textDim,lineHeight:1.55}}>{text}</div>
                                    </div>
                                  ) : null)}
                                </div>
                              )}
                            </div>
                          );
                        })()}
                        {/* Day swimlanes — Mon through Sun */}
                        {(() => {
                          const dayNames = ["Monday","Tuesday","Wednesday","Thursday","Friday","Saturday","Sunday"];
                          const dayAbbrs = ["Mon","Tue","Wed","Thu","Fri","Sat","Sun"];
                          const weekComp = getCompletionForWeek(activePlan, wi + 1);
                          const dayOfWeekMap = { Monday:1, Tuesday:2, Wednesday:3, Thursday:4, Friday:5, Saturday:6, Sunday:0 };
                          return dayNames.map((dayName, laneIdx) => {
                            const di = (week.days || []).findIndex(d => d.day === dayName);
                            const day = di >= 0 ? week.days[di] : null;
                            if (!day) {
                              // Empty lane — no workout assigned to this day
                              return (
                                <div key={dayName} style={{display:"flex",gap:0,minHeight:40,borderBottom:`1px solid ${C.border}20`}}>
                                  <div style={{width:44,flexShrink:0,display:"flex",alignItems:"center",justifyContent:"center",
                                    fontSize:11,fontWeight:700,color:C.textMuted+"80",borderRight:`1px solid ${C.border}20`,
                                    background:C.bg2+"40"}}>
                                    {dayAbbrs[laneIdx]}
                                  </div>
                                  <div style={{flex:1,display:"flex",alignItems:"center",padding:"6px 12px"}}>
                                    <span style={{fontSize:11,color:C.textMuted+"60",fontStyle:"italic"}}>No workout</span>
                                  </div>
                                </div>
                              );
                            }
                            const col = typeColors[day.type] || C.textMuted;
                            const dayKey = `${wi}-${di}`;
                            const isDetailExpanded = expandedDay.has(dayKey);
                            const hasPeloton = !!day.peloton;
                            const isStrength = hasPeloton && /strength|power|climb/i.test(day.peloton.rideType || "");
                            const hasTreadmill = !!day.treadmill && day.indoor && ["easy","recovery","tempo","intervals","long","race_pace","race"].includes(day.type);
                            const hasNutrition = !!day.nutrition;
                            // Normalize hrZone: may be a number (1-5) or a legacy string like "Zone 2 (65–75%…)"
                            const hrZoneNum = (() => {
                              const z = day.hrZone;
                              if (!z) return null;
                              if (typeof z === "number") return z;
                              const m = String(z).match(/\d+/);
                              return m ? parseInt(m[0]) : null;
                            })();
                            const zoneColors = { 1:C.textMuted, 2:C.cyan, 3:C.amber, 4:C.red, 5:C.pink };
                            const zc = zoneColors[hrZoneNum] || C.textMuted;
                            const isDone = day.completed || day.skipped;
                            const isSkipped = day.skipped;
                            // Prefer manually linked activity, then auto-match by day-of-week
                            const linkedAct = day.linkedActivityId
                              ? activities.find(a => a.id === day.linkedActivityId)
                              : null;
                            const matchedActivity = linkedAct
                              ? aggregateDayActivities([linkedAct])
                              : aggregateDayActivities(getDayActivities(weekComp, day.day, day.type, dayOfWeekMap));
                            const isLinked = !!day.linkedActivityId; // manually linked (not auto-matched)
                            const isFirst = laneIdx === 0;
                            const isLast = laneIdx === 6;

                            return (
                              <div key={dayName} style={{
                                display:"flex",flexDirection:"column",
                                borderBottom: isLast ? "none" : `1px solid ${C.border}20`,
                                opacity: isSkipped ? 0.5 : 1,
                              }}>
                                <div style={{display:"flex",gap:0}}>
                                {/* Day label swimlane header */}
                                <div style={{width:44,flexShrink:0,display:"flex",flexDirection:"column",alignItems:"center",justifyContent:"center",
                                  borderRight:`2px solid ${col}40`,background: isDone ? (day.completed ? C.green+"08" : C.red+"06") : C.bg2+"40",
                                  padding:"4px 0",gap:2}}>
                                  <span style={{fontSize:11,fontWeight:700,color: isDone ? (day.completed ? C.green : C.red+"90") : C.textMuted}}>
                                    {dayAbbrs[laneIdx]}
                                  </span>
                                  {/* Move up/down arrows */}
                                  {isOwner && !isDone && (
                                    <>
                                      <button onClick={(e) => { e.stopPropagation(); moveDay(activePlan.id, wi, di, -1); }}
                                        disabled={isFirst}
                                        title="Move to previous day"
                                        style={{background:"none",border:"none",padding:0,cursor:isFirst?"default":"pointer",fontSize:8,color:isFirst?C.border:C.textMuted,lineHeight:1}}>▲</button>
                                      <button onClick={(e) => { e.stopPropagation(); moveDay(activePlan.id, wi, di, 1); }}
                                        disabled={isLast}
                                        title="Move to next day"
                                        style={{background:"none",border:"none",padding:0,cursor:isLast?"default":"pointer",fontSize:8,color:isLast?C.border:C.textMuted,lineHeight:1}}>▼</button>
                                    </>
                                  )}
                                </div>
                                {/* Workout content */}
                                <div style={{flex:1,overflow:"hidden"}}>
                                  <div
                                    onClick={() => day.type !== "rest" && toggleExpandedDay(dayKey)}
                                    style={{
                                      display:"flex",flexDirection:"column",gap:6,padding:"9px 12px",
                                      background: isSkipped ? C.red+"06" : "transparent",cursor: day.type !== "rest" ? "pointer" : "default",
                                    }}>
                                    {/* Row 1 — Header: [checkbox] [emoji] [title] [status badges] [spacer] [actions] */}
                                    <div style={{display:"flex",alignItems:"center",gap:10,flexWrap:"wrap"}}>
                                      {isOwner ? (
                                        <button onClick={(e) => { e.stopPropagation(); toggleDayComplete(activePlan.id, wi, di, matchedActivity); }}
                                          style={{ background:"none", border:`2px solid ${day.completed ? C.green : isSkipped ? C.red+"60" : C.border}`, borderRadius:4, width:18, height:18, cursor:"pointer", flexShrink:0, display:"flex", alignItems:"center", justifyContent:"center", padding:0, color: day.completed ? C.green : "transparent", fontSize:12, lineHeight:1 }}
                                          title={day.completed ? "Mark incomplete" : "Mark complete"}>
                                          {day.completed ? "✓" : isSkipped ? "✕" : ""}
                                        </button>
                                      ) : (
                                        <span style={{ border:`2px solid ${day.completed ? C.green : isSkipped ? C.red+"60" : C.border}`, borderRadius:4, width:18, height:18, flexShrink:0, display:"inline-flex", alignItems:"center", justifyContent:"center", color: day.completed ? C.green : "transparent", fontSize:12, lineHeight:1 }}>
                                          {day.completed ? "✓" : isSkipped ? "✕" : ""}
                                        </span>
                                      )}
                                      <span style={{fontSize:16,width:24,textAlign:"center",flexShrink:0, opacity: isDone ? 0.4 : 1}}>{typeIcons[day.type] || "🏃"}</span>
                                      <span style={{fontSize:13,color: isDone ? C.textMuted : col,fontWeight:700,textTransform:"capitalize",flexShrink:0, textDecoration: isDone ? "line-through" : "none"}}>{day.type?.replace("_"," ")}{isSkipped ? " (skipped)" : ""}</span>
                                      {matchedActivity && (
                                        <span style={{display:"inline-flex",alignItems:"center",gap:3}}>
                                          <span style={{fontSize:10,color:isLinked ? C.cyan : C.green,padding:"2px 6px",background:(isLinked ? C.cyan : C.green)+"15",borderRadius:4,whiteSpace:"nowrap",cursor:isOwner && !day.completed ?"pointer":"default"}}
                                            onClick={isOwner && !day.completed ? (e) => { e.stopPropagation(); toggleDayComplete(activePlan.id, wi, di, matchedActivity); } : undefined}
                                            title={`${isLinked ? "Linked" : "Strava"}: ${(matchedActivity.distance/1609.34).toFixed(1)}mi${matchedActivity.average_heartrate ? `, avg HR ${matchedActivity.average_heartrate}bpm` : ""}${isOwner && !day.completed ? " — click to mark done" : ""}`}>
                                            {isLinked ? "🔗 " : ""}{["cross_train","strength"].includes(day.type) ? "done" : `${(matchedActivity.distance/1609.34).toFixed(1)} mi`}
                                          </span>
                                          {isOwner && isLinked && (
                                            <button onClick={(e) => { e.stopPropagation(); unlinkActivityFromDay(activePlan.id, wi, di); }}
                                              title="Remove link"
                                              style={{background:"none",border:"none",padding:"2px 4px",cursor:"pointer",fontSize:10,color:C.textMuted,lineHeight:1}}>✕</button>
                                          )}
                                        </span>
                                      )}
                                      {/* Orphaned-link recovery: if linkedActivityId is set
                                          but the activity isn't in the visible list (filtered
                                          out, not yet synced, or pointed at a different day's
                                          run), still surface an unlink affordance so the user
                                          isn't stuck with a stale link. */}
                                      {isOwner && isLinked && !matchedActivity && (
                                        <span style={{display:"inline-flex",alignItems:"center",gap:3}}>
                                          <span style={{fontSize:10,color:C.amber,padding:"2px 6px",background:C.amber+"15",borderRadius:4,whiteSpace:"nowrap"}}
                                            title="Linked to an activity that isn't in the current view (filtered out or not synced). You can still unlink.">
                                            🔗 link broken
                                          </span>
                                          <button onClick={(e) => { e.stopPropagation(); unlinkActivityFromDay(activePlan.id, wi, di); }}
                                            title="Remove link"
                                            style={{background:"none",border:"none",padding:"2px 4px",cursor:"pointer",fontSize:10,color:C.textMuted,lineHeight:1}}>✕</button>
                                        </span>
                                      )}
                                      {day.completed && (() => {
                                        const hasStored = day.completedGrade != null;
                                        const gradeLabel = hasStored ? day.completedGrade : null;
                                        const gradeScore = hasStored ? day.completedScore : null;
                                        const gradeDetails = hasStored ? (day.completedGradeDetails || []) : [];
                                        if (!gradeLabel) return null;
                                        // Easy / recovery / long / shakeout days are graded but their
                                        // grade is a *compliance* signal (did HR stay in zone), not a
                                        // fitness signal. HR drifts in summer humidity, so these grades
                                        // are unreliable and shouldn't catch the eye like quality grades.
                                        // Render muted with an "info" hint so the athlete still sees
                                        // the data but isn't alarmed by a string of summer Cs.
                                        const ZONE_TYPES = new Set(["easy", "recovery", "long", "shakeout", "warmup", "cooldown"]);
                                        const isZoneIntent = ZONE_TYPES.has(String(day.type || "").toLowerCase());
                                        if (isZoneIntent) {
                                          return (
                                            <span title={(gradeDetails.length ? gradeDetails.join("; ") + " · " : "") + "Easy/zone-driven grade — informational only, not used to adjust prescribed paces."}
                                              style={{fontSize:10,fontWeight:600,padding:"2px 6px",borderRadius:4,
                                                background:C.textMuted+"15",color:C.textMuted,border:`1px solid ${C.border}`}}>
                                              {gradeLabel} <span style={{ fontSize: 8, opacity: 0.7 }}>info</span>
                                            </span>
                                          );
                                        }
                                        const gradeColor = gradeScore >= 82 ? "#22c55e" : gradeScore >= 67 ? "#f59e0b" : "#ef4444";
                                        return (
                                          <span title={gradeDetails.length ? gradeDetails.join("; ") : "Great execution"}
                                            style={{fontSize:10,fontWeight:700,padding:"2px 6px",borderRadius:4,
                                              background:gradeColor+"20",color:gradeColor,border:`1px solid ${gradeColor}30`}}>
                                            {gradeLabel}
                                          </span>
                                        );
                                      })()}
                                      {/* Spacer pushes action buttons to the right edge */}
                                      <div style={{flex:1,minWidth:8}} />
                                      {/* Action buttons — always in the same position regardless of content */}
                                      <div style={{display:"flex",alignItems:"center",gap:6,flexWrap:"wrap",flexShrink:0}}>
                                        {isOwner && day.type !== "rest" && !day.skipped && (isCurrent || !isPast) && (
                                          <button onClick={(e) => { e.stopPropagation(); pushToGarmin(wi, di); }}
                                            disabled={pushingWorkout === day.day + wi}
                                            title={day.garminWorkoutId ? `Replace on Garmin${day.garminScheduledAt ? ` (scheduled ${day.garminScheduledAt})` : ""}` : "Push to Garmin"}
                                            style={{
                                              background: day.garminWorkoutId ? C.cyan + "15" : "none",
                                              border: `1px solid ${day.garminWorkoutId ? C.cyan + "40" : C.border}`,
                                              borderRadius: 4, padding: "2px 6px", cursor: "pointer",
                                              fontSize: 10, color: day.garminWorkoutId ? C.cyan : C.textMuted, whiteSpace: "nowrap",
                                            }}>
                                            {pushingWorkout === day.day + wi ? "..." : day.garminWorkoutId ? "⌚ ✓" : "⌚"}
                                          </button>
                                        )}
                                        {isOwner && day.type !== "rest" && !day.completed && (
                                          <button onClick={(e) => { e.stopPropagation(); skipDay(activePlan.id, wi, di); }}
                                            title={day.skipped ? "Unskip this day" : "Skip — can't do this workout"}
                                            style={{background:"none",border:`1px solid ${day.skipped ? C.red+"60" : C.border}`,borderRadius:4,padding:"2px 6px",cursor:"pointer",fontSize:10,color: day.skipped ? C.red : C.textMuted}}>
                                            {day.skipped ? "↩" : "Skip"}
                                          </button>
                                        )}
                                        {isOwner && !day.completed && (
                                          <button onClick={(e) => { e.stopPropagation(); setDayRest(activePlan.id, wi, di); }}
                                            title={day.type === "rest" ? "Restore as easy run" : "Set this day as rest"}
                                            style={{background:"none",border:`1px solid ${day.type === "rest" ? C.amber+"60" : C.border}`,borderRadius:4,padding:"2px 6px",cursor:"pointer",fontSize:10,color: day.type === "rest" ? C.amber : C.textMuted,whiteSpace:"nowrap"}}>
                                            {day.type === "rest" ? "↩ Rest" : "Rest"}
                                          </button>
                                        )}
                                        {isOwner && day.type !== "rest" && !day.completed && (
                                          <button
                                            onClick={(e) => { e.stopPropagation(); const key=`${wi}-${di}`; setLinkingDay(linkingDay===key?null:key); setLoggingDay(null); }}
                                            title="Link a Strava activity from another day"
                                            style={{background:linkingDay===`${wi}-${di}`?C.purple+"20":"none",border:`1px solid ${linkingDay===`${wi}-${di}`?C.purple:C.border}`,borderRadius:4,padding:"2px 6px",cursor:"pointer",fontSize:10,color:linkingDay===`${wi}-${di}`?C.purple:C.textMuted,whiteSpace:"nowrap"}}>
                                            🔗 Link
                                          </button>
                                        )}
                                        {isOwner && day.type !== "rest" && !matchedActivity && (
                                          <button
                                            onClick={(e) => {
                                              e.stopPropagation();
                                              const key = `${wi}-${di}`;
                                              if (loggingDay === key) { setLoggingDay(null); }
                                              else {
                                                setLoggingDay(key);
                                                const existing = day.manualActivity;
                                                setLogForm({
                                                  distanceMi: existing?.distanceMi ?? day.distanceMi ?? "",
                                                  durationMin: existing?.durationMin ?? day.durationMin ?? "",
                                                  avgHR: existing?.avgHR ?? "",
                                                  notes: existing?.notes ?? "",
                                                });
                                              }
                                            }}
                                            title={day.manualActivity ? "Edit logged activity" : "Log a manual activity for this day"}
                                            style={{background: loggingDay === `${wi}-${di}` ? C.cyan+"20" : "none", border:`1px solid ${loggingDay === `${wi}-${di}` ? C.cyan : C.border}`,borderRadius:4,padding:"2px 6px",cursor:"pointer",fontSize:10,color: loggingDay === `${wi}-${di}` ? C.cyan : C.textMuted,whiteSpace:"nowrap"}}>
                                            {day.manualActivity ? "✎" : "+ Log"}
                                          </button>
                                        )}
                                        {isOwner && !isDone && (
                                          <button onClick={(e) => { e.stopPropagation(); refreshDay(activePlan.id, wi, di); }}
                                            disabled={generatingDay === `${wi}-${di}`}
                                            title="Refresh this day"
                                            style={{background:"none",border:`1px solid ${C.border}`,borderRadius:4,padding:"2px 6px",cursor:"pointer",fontSize:10,color:C.textMuted}}>
                                            {generatingDay === `${wi}-${di}` ? "…" : "↺"}
                                          </button>
                                        )}
                                        {/* Chevron — explicit expand/collapse affordance.
                                            Multiple days can be expanded at once (state is
                                            a Set), unlike the prior single-string model
                                            where expanding one collapsed every other. */}
                                        {day.type !== "rest" && (
                                          <button onClick={(e) => { e.stopPropagation(); toggleExpandedDay(dayKey); }}
                                            title={isDetailExpanded ? "Collapse details" : "Expand details"}
                                            style={{background:"none",border:`1px solid ${C.border}`,borderRadius:4,padding:"2px 6px",cursor:"pointer",fontSize:11,color:C.textMuted,lineHeight:1,minWidth:24,textAlign:"center"}}>
                                            {isDetailExpanded ? "▾" : "▸"}
                                          </button>
                                        )}
                                      </div>
                                    </div>

                                    {/* Row 2 — Meta: [Z badge] [miles] [pace] [peloton] [fuel] [description].
                                        Rendered on every non-rest day so the layout stays identical.
                                        The 32px left indent aligns metadata under the title column. */}
                                    {day.type !== "rest" && (hrZoneNum || day.distanceMi > 0 || day.targetPace || hasPeloton || hasNutrition || day.description) && (
                                      <div style={{display:"flex",alignItems:"baseline",gap:10,flexWrap:"wrap",paddingLeft:32}}>
                                        {hrZoneNum && (
                                          <span style={{padding:"2px 7px",borderRadius:4,fontSize:10,fontWeight:700,background:zc+"20",border:`1px solid ${zc}40`,color:zc,whiteSpace:"nowrap",flexShrink:0}}>Z{hrZoneNum}</span>
                                        )}
                                        {day.distanceMi > 0 && (
                                          <span style={{fontSize:11,color:C.textMuted,whiteSpace:"nowrap",flexShrink:0}}>{day.distanceMi} mi</span>
                                        )}
                                        {day.targetPace && (
                                          <span style={{fontSize:11,color:C.amber,whiteSpace:"nowrap",flexShrink:0}}>{planLivePace(day)}/mi</span>
                                        )}
                                        {hasPeloton && (
                                          <span style={{fontSize:10,color:"#60a5fa",padding:"2px 6px",background:"#60a5fa15",borderRadius:4,whiteSpace:"nowrap",flexShrink:0}}>🚴 Peloton</span>
                                        )}
                                        {hasNutrition && (
                                          <span style={{fontSize:10,color:C.green,padding:"2px 6px",background:C.green+"15",borderRadius:4,whiteSpace:"nowrap",flexShrink:0}}>🍌 Fuel</span>
                                        )}
                                        {/* Strides: prescription detector. Day.type stays "easy" /
                                            "long" (planRules doesn't have a stride type), so the
                                            only signal is the description text or a step group of
                                            short fast reps. Surface a chip so users can see at a
                                            glance which easy days include neuromuscular work.
                                            Guard against false positives from negated mentions
                                            ("no strides today", "skip strides", "without strides")
                                            and require an affirmative prescription pattern. */}
                                        {(() => {
                                          const desc = (day.description || "").toLowerCase();
                                          const negated = /\b(no|skip|without|drop|don'?t\s+(?:add|do|include))\s+strides?\b/.test(desc)
                                            || /\bstrides?\s+(?:are\s+)?(?:omitted|skipped|optional|off)\b/.test(desc);
                                          // Affirmative: counted prescription ("6x100m strides",
                                          // "8 strides"), explicit "+ strides" / "with strides" /
                                          // "add strides", or "neuromuscular" — strong signals
                                          // that strides are actually prescribed.
                                          const affirmative = /\b\d+\s*[x×]\s*(?:\d+(?:m|s|sec|min)?\s*)?strides?\b/.test(desc)
                                            || /\b\d+\s*strides?\b/.test(desc)
                                            || /\b(?:\+|with|add(?:ing)?|include|finish\s+with|end\s+with)\s+(?:\d+\s*[x×]?\s*\d*m?\s*)?strides?\b/.test(desc)
                                            || /\bneuromuscular\b/.test(desc);
                                          const hasStrideText = affirmative && !negated;
                                          const hasStrideStep = Array.isArray(day.steps) && day.steps.some(s =>
                                            s && (s.repeat || s.type === "repeat") && Array.isArray(s.children) &&
                                            s.children.some(c => c && c.distance != null && c.distance > 0 && c.distance < 0.2));
                                          if (!hasStrideText && !hasStrideStep) return null;
                                          return <span style={{fontSize:10,color:C.purple,padding:"2px 6px",background:C.purple+"18",border:`1px solid ${C.purple}40`,borderRadius:4,whiteSpace:"nowrap",flexShrink:0,fontWeight:600}}>⚡ Strides</span>;
                                        })()}
                                        {day.description && (
                                          <div style={{flex:"1 1 200px",minWidth:0,fontSize:12,color: isDone ? C.textMuted : C.textDim,lineHeight:1.5, textDecoration: isDone ? "line-through" : "none", opacity: isDone ? 0.6 : 1}}>{planText(day.description)}</div>
                                        )}
                                      </div>
                                    )}
                                  </div>
                                </div>
                                </div>

                              {/* Manual activity log form (inline, opens via "+ Log" button) */}
                              {isOwner && loggingDay === `${wi}-${di}` && (
                                <div onClick={e => e.stopPropagation()}
                                  style={{borderTop:`1px solid ${C.cyan}30`,background:C.cyan+"08",padding:"12px 14px",display:"flex",flexDirection:"column",gap:10}}>
                                  <div style={{fontSize:11,fontWeight:700,color:C.cyan,marginBottom:2}}>Log activity for {day.day}</div>
                                  <div style={{display:"flex",gap:10,flexWrap:"wrap"}}>
                                    <label style={{display:"flex",flexDirection:"column",gap:3,fontSize:11,color:C.textMuted,flex:"1 1 80px"}}>
                                      Distance (mi)
                                      <input type="number" step="0.1" min="0" placeholder={day.distanceMi || "0.0"}
                                        value={logForm.distanceMi}
                                        onChange={e => setLogForm(f => ({...f, distanceMi: e.target.value}))}
                                        style={{background:C.bg3,border:`1px solid ${C.border}`,borderRadius:6,padding:"5px 8px",color:C.text,fontSize:12,width:"100%"}} />
                                    </label>
                                    <label style={{display:"flex",flexDirection:"column",gap:3,fontSize:11,color:C.textMuted,flex:"1 1 80px"}}>
                                      Duration (min)
                                      <input type="number" step="1" min="0" placeholder={day.durationMin || "60"}
                                        value={logForm.durationMin}
                                        onChange={e => setLogForm(f => ({...f, durationMin: e.target.value}))}
                                        style={{background:C.bg3,border:`1px solid ${C.border}`,borderRadius:6,padding:"5px 8px",color:C.text,fontSize:12,width:"100%"}} />
                                    </label>
                                    <label style={{display:"flex",flexDirection:"column",gap:3,fontSize:11,color:C.textMuted,flex:"1 1 80px"}}>
                                      Avg HR (bpm)
                                      <input type="number" step="1" min="40" max="220" placeholder="optional"
                                        value={logForm.avgHR}
                                        onChange={e => setLogForm(f => ({...f, avgHR: e.target.value}))}
                                        style={{background:C.bg3,border:`1px solid ${C.border}`,borderRadius:6,padding:"5px 8px",color:C.text,fontSize:12,width:"100%"}} />
                                    </label>
                                  </div>
                                  <label style={{display:"flex",flexDirection:"column",gap:3,fontSize:11,color:C.textMuted}}>
                                    Notes (optional)
                                    <input type="text" placeholder="e.g. Treadmill, felt strong, hill reps…"
                                      value={logForm.notes}
                                      onChange={e => setLogForm(f => ({...f, notes: e.target.value}))}
                                      style={{background:C.bg3,border:`1px solid ${C.border}`,borderRadius:6,padding:"5px 8px",color:C.text,fontSize:12}} />
                                  </label>
                                  <div style={{display:"flex",gap:8}}>
                                    <button
                                      onClick={() => logManualActivity(activePlan.id, wi, di)}
                                      style={{background:C.cyan,border:"none",borderRadius:6,padding:"6px 16px",cursor:"pointer",fontSize:12,fontWeight:700,color:"#000"}}>
                                      Save &amp; Complete
                                    </button>
                                    <button
                                      onClick={() => setLoggingDay(null)}
                                      style={{background:"none",border:`1px solid ${C.border}`,borderRadius:6,padding:"6px 12px",cursor:"pointer",fontSize:12,color:C.textMuted}}>
                                      Cancel
                                    </button>
                                  </div>
                                </div>
                              )}

                              {/* Manual activity badge (shown in row when logged manually) */}
                              {day.manualActivity && day.completed && (
                                <div style={{padding:"4px 14px 6px",display:"flex",alignItems:"center",gap:8,flexWrap:"wrap"}}>
                                  <span style={{fontSize:10,color:C.cyan,padding:"2px 7px",background:C.cyan+"15",borderRadius:4,border:`1px solid ${C.cyan}30`,whiteSpace:"nowrap"}}>
                                    ✎ Manual
                                    {day.manualActivity.distanceMi ? ` · ${day.manualActivity.distanceMi}mi` : ""}
                                    {day.manualActivity.durationMin ? ` · ${day.manualActivity.durationMin}min` : ""}
                                    {day.manualActivity.avgHR ? ` · ${day.manualActivity.avgHR}bpm` : ""}
                                  </span>
                                  {day.manualActivity.notes && (
                                    <span style={{fontSize:10,color:C.textMuted,fontStyle:"italic"}}>{day.manualActivity.notes}</span>
                                  )}
                                </div>
                              )}

                              {/* Activity picker (link a Strava run from a different day) */}
                              {isOwner && linkingDay === `${wi}-${di}` && (() => {
                                const runTypes = ["easy","recovery","tempo","intervals","long","race_pace","race"];
                                const isRunDay = runTypes.includes(day.type);
                                const cutoff = new Date(); cutoff.setDate(cutoff.getDate() - 21);
                                const candidates = activities
                                  .filter(a => {
                                    const d = a.start_date?.seconds ? new Date(a.start_date.seconds*1000) : new Date(a.start_date);
                                    // Honor typeOverride so a re-tagged ride can't be linked to a
                                    // run day (which would write its distance into running miles).
                                    return d >= cutoff && isRun(a) === isRunDay && a.distance > 0;
                                  })
                                  .sort((a,b) => (b.start_date?.seconds||0) - (a.start_date?.seconds||0))
                                  .slice(0, 20);
                                return (
                                  <div onClick={e => e.stopPropagation()}
                                    style={{borderTop:`1px solid ${C.purple}30`,background:C.purple+"08",padding:"12px 14px",display:"flex",flexDirection:"column",gap:8}}>
                                    <div style={{fontSize:11,fontWeight:700,color:C.purple,marginBottom:2}}>
                                      Link activity to {day.day} · {day.type?.replace("_"," ")}
                                      <span style={{fontWeight:400,color:C.textMuted,marginLeft:6}}>select a run from your recent history</span>
                                    </div>
                                    {candidates.length === 0 ? (
                                      <div style={{fontSize:11,color:C.textMuted}}>No recent {isRunDay?"runs":"activities"} found in the last 3 weeks.</div>
                                    ) : (
                                      <div style={{display:"flex",flexDirection:"column",gap:4,maxHeight:220,overflowY:"auto"}}>
                                        {candidates.map(a => {
                                          const d = a.start_date?.seconds ? new Date(a.start_date.seconds*1000) : new Date(a.start_date);
                                          const distMi = (a.distance/1609.34).toFixed(1);
                                          const paceMin = a.moving_time && a.distance > 0 ? a.moving_time/(a.distance/1609.34)/60 : null;
                                          const paceFmt = paceMin ? `${Math.floor(paceMin)}:${Math.round((paceMin%1)*60).toString().padStart(2,"0")}/mi` : null;
                                          const dateStr = d.toLocaleDateString("en-US",{weekday:"short",month:"short",day:"numeric"});
                                          const isCurrentLinked = day.linkedActivityId === a.id;
                                          return (
                                            <button key={a.id||a.name} onClick={() => linkActivityToDay(activePlan.id, wi, di, a)}
                                              style={{background:isCurrentLinked?C.purple+"20":C.bg3,border:`1px solid ${isCurrentLinked?C.purple:C.border}`,
                                                borderRadius:6,padding:"8px 10px",cursor:"pointer",display:"flex",justifyContent:"space-between",
                                                alignItems:"center",gap:8,textAlign:"left",width:"100%"}}>
                                              <div style={{display:"flex",flexDirection:"column",gap:1}}>
                                                <span style={{fontSize:11,color:C.text,fontWeight:600}}>{a.name||a.type}</span>
                                                <span style={{fontSize:10,color:C.textMuted}}>{dateStr}</span>
                                              </div>
                                              <div style={{display:"flex",gap:8,alignItems:"center",flexShrink:0}}>
                                                <span style={{fontSize:11,color:C.amber}}>{distMi} mi</span>
                                                {paceFmt && <span style={{fontSize:10,color:C.textMuted}}>{paceFmt}</span>}
                                                {a.average_heartrate && <span style={{fontSize:10,color:C.pink}}>{Math.round(a.average_heartrate)} bpm</span>}
                                              </div>
                                            </button>
                                          );
                                        })}
                                      </div>
                                    )}
                                    <button onClick={()=>setLinkingDay(null)}
                                      style={{background:"none",border:`1px solid ${C.border}`,borderRadius:6,padding:"5px 10px",cursor:"pointer",fontSize:11,color:C.textMuted,alignSelf:"flex-start"}}>
                                      Cancel
                                    </button>
                                  </div>
                                );
                              })()}

                              {/* Expanded detail panel */}
                              {isDetailExpanded && (
                                <div style={{borderTop:`1px solid ${C.border}40`,padding:"12px 14px",display:"flex",flexDirection:"column",gap:10}}>

                                  {/* Garmin snapshot — readiness, body battery, sleep,
                                      acute load from the matching health entry for this
                                      workout's date. Pulled directly from the live
                                      Firestore health collection via useData(). */}
                                  {(() => {
                                    const dateStr = getWorkoutDate(activePlan, week.week, day.day);
                                    if (!dateStr) return null;
                                    const h = (healthEntries || []).find(e => e.date === dateStr);
                                    if (!h) return null;
                                    const readiness = h.trainingReadiness?.score ?? h.readiness ?? null;
                                    const bbStart = h.bodyBattery?.startLevel ?? null;
                                    const bbEnd = h.bodyBattery?.endLevel ?? null;
                                    const sleepScore = h.sleep?.score ?? null;
                                    const sleepHrs = h.sleep?.totalSleepSeconds ? (h.sleep.totalSleepSeconds / 3600) : null;
                                    const acl = h.trainingLoad?.acute ?? h.acuteTrainingLoad ?? null;
                                    const anyStat = [readiness, bbStart, sleepScore, acl].some(v => v != null);
                                    if (!anyStat) return null;
                                    const readyColor = readiness == null ? C.textMuted
                                      : readiness >= 70 ? C.green : readiness >= 40 ? C.amber : C.red;
                                    return (
                                      <div style={{padding:"10px 12px",background:C.bg2,border:`1px solid ${C.border}`,borderRadius:8}}>
                                        <div style={{fontSize:10,color:C.textMuted,fontWeight:700,letterSpacing:"0.06em",marginBottom:8}}>
                                          GARMIN · {dateStr}
                                        </div>
                                        <div style={{display:"grid",gridTemplateColumns:"repeat(auto-fit,minmax(110px,1fr))",gap:10}}>
                                          {readiness != null && (
                                            <div>
                                              <div style={{fontSize:18,fontWeight:800,color:readyColor,fontFamily:"'Space Grotesk',sans-serif"}}>{readiness}<span style={{fontSize:11,color:C.textMuted,fontWeight:500}}>/100</span></div>
                                              <div style={{fontSize:10,color:C.textMuted}}>Training Readiness</div>
                                            </div>
                                          )}
                                          {bbStart != null && (
                                            <div>
                                              <div style={{fontSize:14,fontWeight:700,color:C.cyan}}>
                                                {bbStart}{bbEnd != null ? ` → ${bbEnd}` : ""}
                                              </div>
                                              <div style={{fontSize:10,color:C.textMuted}}>Body Battery</div>
                                            </div>
                                          )}
                                          {sleepScore != null && (
                                            <div>
                                              <div style={{fontSize:14,fontWeight:700,color:C.purple}}>
                                                {sleepScore}{sleepHrs != null ? ` · ${sleepHrs.toFixed(1)}h` : ""}
                                              </div>
                                              <div style={{fontSize:10,color:C.textMuted}}>Sleep Score</div>
                                            </div>
                                          )}
                                          {acl != null && (
                                            <div>
                                              <div style={{fontSize:14,fontWeight:700,color:C.amber}}>{Math.round(acl)}</div>
                                              <div style={{fontSize:10,color:C.textMuted}}>Acute Load</div>
                                            </div>
                                          )}
                                        </div>
                                      </div>
                                    );
                                  })()}

                                  {/* Structured workout steps — surfaces day.steps so the
                                      athlete can see WU / main set / CD as discrete blocks
                                      instead of having to parse it out of the prose. The
                                      summary row at the bottom totals step distances so any
                                      mismatch with day.distanceMi (Claude's contractual
                                      mileage) is visible at a glance, which is the answer
                                      to "how long do I actually need to hold this pace?". */}
                                  {Array.isArray(day.steps) && day.steps.length > 0 && (() => {
                                    const flatten = (arr) => (arr || []).flatMap(s => (
                                      s && s.type === "repeat" && Array.isArray(s.children)
                                        ? Array.from({ length: s.repeat || 1 }, () => flatten(s.children)).flat()
                                        : [s]
                                    ));
                                    const flat = flatten(day.steps);
                                    // Distance accounting: prefer the explicit step.distance,
                                    // but for duration-based steps (e.g. "10min @ 8:15") imply
                                    // distance from duration / paceSec so the total reflects
                                    // what the athlete actually runs. Without this the panel
                                    // under-reported time-based cruise intervals as 0 miles
                                    // and the mismatch flag fired spuriously.
                                    const parsePaceSec = (p) => {
                                      if (p == null) return null;
                                      const m = String(p).trim().match(/^(\d+):(\d{1,2})$/);
                                      if (!m) return null;
                                      const s = parseInt(m[1], 10) * 60 + parseInt(m[2], 10);
                                      return s > 0 ? s : null;
                                    };
                                    const stepMi = flat.reduce((sum, s) => {
                                      const explicit = Number(s?.distance) || 0;
                                      if (explicit > 0) return sum + explicit;
                                      const dur = Number(s?.duration) || 0;
                                      const paceSec = parsePaceSec(s?.pace);
                                      if (dur > 0 && paceSec) return sum + (dur / paceSec);
                                      return sum;
                                    }, 0);
                                    const stepSec = flat.reduce((sum, s) => sum + (Number(s?.duration) || 0), 0);
                                    const stepColor = (t) => {
                                      const k = String(t || "").toLowerCase();
                                      if (k === "warmup") return C.green;
                                      if (k === "cooldown") return C.cyan;
                                      if (k === "interval" || k === "tempo") return C.amber;
                                      if (k === "recovery") return C.textMuted;
                                      if (k === "easy") return C.green;
                                      return C.textDim;
                                    };
                                    const stepLabel = (t) => {
                                      const k = String(t || "").toLowerCase();
                                      if (k === "warmup") return "WU";
                                      if (k === "cooldown") return "CD";
                                      if (k === "interval") return "Work";
                                      if (!k) return "Step";
                                      return k.charAt(0).toUpperCase() + k.slice(1);
                                    };
                                    const renderRow = (s, key, indent = 0) => {
                                      if (s && s.type === "repeat" && Array.isArray(s.children)) {
                                        return (
                                          <div key={key} style={{ marginLeft: indent }}>
                                            <div style={{ fontSize: 11, color: C.text, fontWeight: 700, padding: "2px 0" }}>
                                              {s.repeat}× repeat
                                            </div>
                                            <div style={{ borderLeft: `2px solid ${C.border}`, paddingLeft: 8, marginLeft: 4 }}>
                                              {(s.children || []).map((c, i) => renderRow(c, `${key}-${i}`, 0))}
                                            </div>
                                          </div>
                                        );
                                      }
                                      const accent = stepColor(s?.type);
                                      const dist = Number(s?.distance) > 0 ? `${s.distance}mi` : null;
                                      const dur = !dist && Number(s?.duration) > 0 ? `${Math.round(s.duration / 60)}min` : null;
                                      const hr = s?.targetHR && (s.targetHR.low || s.targetHR.high) ? `${s.targetHR.low || "?"}-${s.targetHR.high || "?"} bpm` : null;
                                      return (
                                        <div key={key} style={{ display: "flex", alignItems: "center", gap: 8, padding: "4px 0", fontSize: 12 }}>
                                          <span style={{ fontSize: 10, color: accent, padding: "2px 6px", background: accent + "18", borderRadius: 4, fontWeight: 700, minWidth: 38, textAlign: "center" }}>
                                            {stepLabel(s?.type)}
                                          </span>
                                          <span style={{ color: C.text, fontFamily: "'IBM Plex Mono', monospace", minWidth: 50 }}>
                                            {dist || dur || "—"}
                                          </span>
                                          {s?.pace && <span style={{ color: C.amber, fontFamily: "'IBM Plex Mono', monospace" }}>@ {s.pace}</span>}
                                          {hr && <span style={{ color: C.red, fontSize: 11 }}>{hr}</span>}
                                          {s?.description && <span style={{ color: C.textMuted, fontSize: 11 }}>· {s.description}</span>}
                                        </div>
                                      );
                                    };
                                    const dayMi = Number(day.distanceMi) || 0;
                                    const mismatch = dayMi > 0 && stepMi > 0 && Math.abs(stepMi - dayMi) > 0.3;
                                    return (
                                      <div style={{ padding: "10px 12px", background: C.bg2, border: `1px solid ${C.border}`, borderRadius: 8 }}>
                                        <div style={{ fontSize: 10, color: C.textMuted, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.06em", marginBottom: 6 }}>
                                          Workout structure
                                        </div>
                                        {day.steps.map((s, i) => renderRow(s, `s-${i}`))}
                                        <div style={{ marginTop: 6, paddingTop: 6, borderTop: `1px solid ${C.border}`, display: "flex", justifyContent: "space-between", fontSize: 11 }}>
                                          <span style={{ color: C.textMuted }}>Steps total</span>
                                          <span style={{ color: mismatch ? C.amber : C.text, fontFamily: "'IBM Plex Mono', monospace", fontWeight: 700 }}>
                                            {stepMi > 0 ? `${stepMi.toFixed(1)} mi` : `${Math.round(stepSec / 60)} min`}
                                            {dayMi > 0 && <span style={{ color: C.textMuted, fontWeight: 400 }}> · day {dayMi}mi</span>}
                                            {mismatch && <span style={{ color: C.amber, fontSize: 10, marginLeft: 6 }}>⚠ mismatch</span>}
                                          </span>
                                        </div>
                                      </div>
                                    );
                                  })()}

                                  {/* Target HR display — the plan's parsed targetHR wins
                                      over re-derived zone bounds. Plans prescribe a
                                      specific range (e.g. "@ 167-176 HR") which the user
                                      trained to and the workout was graded against;
                                      overlaying current zone bounds shows nonsense when
                                      the user's HR zones drift from the plan's anchors. */}
                                  {(hrZoneNum || day.targetHR) && (() => {
                                    const liveZone = hrZoneNum && hrZones?.zones ? hrZones.zones.find(z => z.zone === hrZoneNum) : null;
                                    const hrLow = day.targetHR?.low ?? (liveZone ? liveZone.minBPM : null);
                                    const hrHigh = day.targetHR?.high ?? (liveZone ? liveZone.maxBPM : null);
                                    if (!hrLow && !hrHigh) return null;
                                    return (
                                      <div style={{display:"flex",alignItems:"center",gap:12,padding:"8px 12px",background:C.red+"08",border:`1px solid ${C.red}25`,borderRadius:8}}>
                                        <span style={{fontSize:20}}>❤️</span>
                                        <div>
                                          <div style={{fontSize:10,color:C.red,fontWeight:700,textTransform:"uppercase",letterSpacing:"0.06em"}}>
                                            Target HR {(() => {
                                              // Mirror the Garmin-push modality so the label matches what the
                                              // watch enforces: a TIMED work effort with an HR band is HR-led
                                              // (e.g. 3×10min at Z3), a DISTANCE-based rep at a pace is
                                              // pace-led (800m @ 5:50, MP miles). Tempo days used to be
                                              // blanket-labelled "pace primary" even when the work was HR-gauged.
                                              const WORK = ["tempo","interval","intervals","race_pace"];
                                              const flat = [];
                                              const walk = (arr) => (arr || []).forEach(s => {
                                                if (s && s.type === "repeat" && Array.isArray(s.children)) walk(s.children);
                                                else if (s && WORK.includes(s.type)) flat.push(s);
                                              });
                                              walk(Array.isArray(day.steps) ? day.steps : []);
                                              if (!flat.length && ["tempo","intervals","race_pace"].includes(day.type)) {
                                                flat.push({ distance: day.distanceMi || null, duration: (!day.distanceMi && day.durationMin) ? day.durationMin * 60 : null });
                                              }
                                              let pacePrimary;
                                              if (flat.length) {
                                                const timed = flat.filter(s => s.duration && !s.distance).length;
                                                const paced = flat.filter(s => s.distance).length;
                                                pacePrimary = !day.targetHR || paced > timed;
                                              } else {
                                                pacePrimary = false; // easy / long / recovery — HR is the primary measure
                                              }
                                              return pacePrimary ? "(pace primary)" : "(HR primary)";
                                            })()}
                                          </div>
                                          <div style={{fontSize:18,fontWeight:700,color:C.text,fontFamily:"'Space Grotesk',sans-serif"}}>
                                            {hrLow}–{hrHigh} <span style={{fontSize:11,color:C.textMuted}}>bpm</span>
                                          </div>
                                        </div>
                                        {hrZoneNum && <span style={{fontSize:11,color:zc,padding:"3px 10px",background:zc+"15",borderRadius:6,fontWeight:700}}>Z{hrZoneNum}</span>}
                                      </div>
                                    );
                                  })()}

                                  {/* Activity insights for completed workouts */}
                                  {day.completed && matchedActivity && (() => {
                                    const actMi = matchedActivity.distance / 1609.34;
                                    const planMi = day.distanceMi || (() => { const m = (day.description || "").match(/(\d+\.?\d*)\s*mi/i); return m ? parseFloat(m[1]) : 0; })();
                                    const avgHR = matchedActivity.average_heartrate;
                                    const maxHR = matchedActivity.max_heartrate;
                                    const time = matchedActivity.moving_time;
                                    const pace = time && actMi > 0 ? time / actMi : 0;
                                    const notes = [];

                                    // Distance analysis
                                    if (planMi > 0) {
                                      const diff = actMi - planMi;
                                      if (Math.abs(diff) < planMi * 0.05) notes.push({ text: `Distance spot-on: ${actMi.toFixed(1)}mi vs ${planMi}mi planned`, color: C.green });
                                      else if (diff > 0) notes.push({ text: `Ran ${diff.toFixed(1)}mi extra (${actMi.toFixed(1)} vs ${planMi}mi planned)${diff > planMi * 0.2 ? " — watch cumulative overreach" : ""}`, color: diff > planMi * 0.2 ? C.amber : C.green });
                                      else notes.push({ text: `${Math.abs(diff).toFixed(1)}mi short (${actMi.toFixed(1)} vs ${planMi}mi planned)`, color: Math.abs(diff) > planMi * 0.2 ? C.amber : C.textMuted });
                                    }

                                    // HR analysis
                                    if (avgHR && ["easy","recovery","long"].includes(day.type)) {
                                      if (avgHR < 144) notes.push({ text: `Avg HR ${avgHR} bpm — perfect Z1 aerobic effort`, color: C.green });
                                      else if (avgHR < 150) notes.push({ text: `Avg HR ${avgHR} bpm — slightly above Z1 ceiling (144). Keep it easier.`, color: C.amber });
                                      else notes.push({ text: `Avg HR ${avgHR} bpm — too hard for an easy day. Z1 ceiling is 144 bpm.`, color: C.red });
                                    }

                                    // Pace analysis. Skip mixed-pace interval-style
                                    // workouts here — the overall average pace of a
                                    // session that mixes warmup, reps, recovery jogs
                                    // and cooldown doesn't represent execution; the
                                    // grading panel already handles those correctly.
                                    const MIXED_PACE_TYPES = ["intervals","interval","race_pace"];
                                    if (pace > 0 && day.targetPace && !MIXED_PACE_TYPES.includes(day.type)) {
                                      const tParts = String(day.targetPace).split(":").map(p => parseInt(p, 10));
                                      const tSec = (tParts.length === 2 && tParts.every(Number.isFinite)) ? tParts[0]*60 + tParts[1] : null;
                                      if (tSec) {
                                        const diff = pace - tSec;
                                        if (Math.abs(diff) < 15) notes.push({ text: `Pace on target: ${fmt.pace(pace).split(" ")[0]}/mi`, color: C.green });
                                        else if (diff < 0) {
                                          const isEasyDay = ["easy","recovery","long"].includes(day.type);
                                          // HR is ground truth for easy days: faster than the
                                          // modelled target while avg HR stays under the Z1
                                          // ceiling means aerobic fitness improved, not that
                                          // the run was undisciplined. Scolding "easy days
                                          // should be easy" here contradicts the HR data.
                                          if (isEasyDay && avgHR && avgHR < 144) notes.push({ text: `Faster than target by ${Math.abs(Math.round(diff))}s/mi but HR confirms Z1 — easy fitness improving, target pace may be stale`, color: C.cyan });
                                          else if (isEasyDay) notes.push({ text: `Faster than target by ${Math.abs(Math.round(diff))}s/mi — easy days should be easy!`, color: C.amber });
                                          else notes.push({ text: `Faster than target by ${Math.abs(Math.round(diff))}s/mi — strong execution`, color: C.green });
                                        }
                                        else notes.push({ text: `${Math.round(diff)}s/mi slower than target — ${day.type === "easy" ? "fine for easy days" : "consider if fatigue was a factor"}`, color: day.type === "easy" ? C.green : C.amber });
                                      }
                                    }

                                    // Stride detection insight
                                    if (maxHR && avgHR && maxHR - avgHR > 22 && maxHR >= 163 && /stride/i.test(day.description || "")) {
                                      notes.push({ text: `Strides confirmed: max HR ${maxHR} bpm shows good neuromuscular activation while keeping easy HR at ${avgHR} bpm`, color: C.cyan });
                                    }

                                    // Running dynamics insights (from Garmin)
                                    const rd = matchedActivity.dynamics;
                                    if (rd) {
                                      if (rd.groundContactTime) {
                                        const gct = rd.groundContactTime;
                                        const gctColor = gct < 220 ? C.green : gct < 260 ? C.cyan : gct < 300 ? C.amber : C.red;
                                        notes.push({ text: `GCT: ${gct}ms${gct < 230 ? " — elite range" : gct < 260 ? " — good" : " — work on quick turnover"}`, color: gctColor });
                                      }
                                      if (rd.groundContactBalance) {
                                        const bal = rd.groundContactBalance;
                                        const imbalance = Math.abs(bal - 50);
                                        if (imbalance > 2) notes.push({ text: `L/R balance: ${bal.toFixed(1)}% / ${(100-bal).toFixed(1)}% — ${imbalance.toFixed(1)}% imbalance, watch for asymmetry`, color: C.amber });
                                        else notes.push({ text: `L/R balance: ${bal.toFixed(1)}% / ${(100-bal).toFixed(1)}% — well balanced`, color: C.green });
                                      }
                                      if (rd.verticalOscillation) {
                                        const vo = rd.verticalOscillation;
                                        notes.push({ text: `Vertical oscillation: ${vo.toFixed(1)}cm${vo < 7 ? " — efficient" : vo < 9 ? " — good" : " — reduce bounce, focus on forward drive"}`, color: vo < 8 ? C.green : vo < 10 ? C.cyan : C.amber });
                                      }
                                      if (rd.cadenceSpm) {
                                        const cad = rd.cadenceSpm;
                                        notes.push({ text: `Cadence: ${cad} spm${cad >= 170 ? " — optimal" : cad >= 160 ? " — good" : " — try shorter, quicker steps"}`, color: cad >= 170 ? C.green : cad >= 160 ? C.cyan : C.amber });
                                      }
                                      if (rd.power) {
                                        notes.push({ text: `Power: ${rd.power}W${rd.normalizedPower ? ` (NP: ${rd.normalizedPower}W)` : ""}`, color: C.cyan });
                                      }
                                      if (rd.trainingEffect) {
                                        const te = typeof rd.trainingEffect === "object" ? rd.trainingEffect.aerobic : rd.trainingEffect;
                                        if (te) notes.push({ text: `Training effect: ${te.toFixed(1)}${te >= 3 ? " — maintaining/improving fitness" : te >= 2 ? " — base building" : " — recovery stimulus"}`, color: te >= 3 ? C.green : C.cyan });
                                      }
                                    }

                                    // Elevation insight
                                    if (matchedActivity.total_elevation_gain > 50) {
                                      notes.push({ text: `Elevation: ${Math.round(matchedActivity.total_elevation_gain * 3.28)}ft gain — good hill training for race prep`, color: C.cyan });
                                    }

                                    if (notes.length === 0) notes.push({ text: "Solid execution — workout completed as planned", color: C.green });

                                    return (
                                      <div style={{display:"flex",flexDirection:"column",gap:4,padding:"8px 12px",background:C.bg2,borderRadius:8}}>
                                        <div style={{fontSize:10,fontWeight:600,color:C.textDim,textTransform:"uppercase",letterSpacing:"0.05em"}}>Activity Insights</div>
                                        {notes.map((n, ni) => (
                                          <div key={ni} style={{display:"flex",alignItems:"center",gap:6,fontSize:11}}>
                                            <span style={{width:5,height:5,borderRadius:"50%",background:n.color,flexShrink:0}} />
                                            <span style={{color:n.color}}>{n.text}</span>
                                          </div>
                                        ))}
                                      </div>
                                    );
                                  })()}

                                  {/* Indoor/outdoor/track badge + variant toggle.
                                      Track variant only exists for quality workout types
                                      (tempo/intervals/race_pace) — the ensureDayVariants
                                      helper on the backend enforces that contract. */}
                                  <div style={{display:"flex",gap:8,alignItems:"center",flexWrap:"wrap"}}>
                                    {(() => {
                                      const availableVariants = ["outdoor", "indoor"];
                                      if (day.variants?.track) availableVariants.splice(1, 0, "track"); // track between outdoor and indoor
                                      const canToggle = isOwner && day.variants?.indoor && day.variants?.outdoor && !day.completed && !day.locked;
                                      const currentKey = day.activeVariant || (day.venue === "track" ? "track" : (day.indoor ? "indoor" : "outdoor"));
                                      const venueAccent = (v) => v === "indoor" ? C.cyan : v === "track" ? C.amber : C.green;
                                      const venueLabel = (v) => v === "indoor" ? "🏠 Indoor" : v === "track" ? "🏟 Track" : "🌤 Outdoor";
                                      if (canToggle) {
                                        return (
                                          <div style={{display:"inline-flex",border:`1px solid ${C.border}`,borderRadius:6,overflow:"hidden",fontSize:10,fontWeight:600}}>
                                            {availableVariants.map(v => {
                                              const isActive = currentKey === v;
                                              const accent = venueAccent(v);
                                              return (
                                                <button key={v} onClick={() => switchDayEnvironment(activePlan.id, wi, di, v)}
                                                  title={`Switch to pre-generated ${v} version`}
                                                  style={{
                                                    background: isActive ? accent + "20" : "transparent",
                                                    color: isActive ? accent : C.textMuted,
                                                    border: "none",
                                                    padding: "3px 10px",
                                                    cursor: "pointer",
                                                    fontFamily: "'IBM Plex Mono',monospace",
                                                  }}>
                                                  {venueLabel(v)}
                                                </button>
                                              );
                                            })}
                                          </div>
                                        );
                                      }
                                      const accent = venueAccent(currentKey);
                                      return (
                                        <span style={{fontSize:10,color: accent, padding:"3px 8px", background: accent + "15", borderRadius:4, fontWeight:600}}>
                                          {venueLabel(currentKey)}
                                        </span>
                                      );
                                    })()}
                                    {day.distanceMi > 0 && <span style={{fontSize:11,color:C.textMuted}}>{day.distanceMi} mi</span>}
                                    {day.targetPace && (
                                      <span style={{fontSize:11,color:C.amber}}>{planLivePace(day)}/mi</span>
                                    )}
                                    {day.durationMin && <span style={{fontSize:11,color:C.textMuted}}>{day.durationMin} min</span>}
                                  </div>

                                  {/* Edit day button + inline editor */}
                                  {isOwner && editingDay?.wi === wi && editingDay?.di === di ? (
                                    <div style={{display:"flex",flexDirection:"column",gap:8,padding:"10px 12px",background:C.bg2,borderRadius:8,border:`1px solid ${C.border}`}}>
                                      <div style={{display:"grid",gridTemplateColumns:"1fr 1fr",gap:8}}>
                                        <div>
                                          <div style={{fontSize:10,color:C.textMuted,marginBottom:3}}>Day</div>
                                          <select value={editingDay.data.day || week.days[di]?.day} onChange={e => changeDayTo(activePlan.id, wi, di, e.target.value)} style={{width:"100%",fontSize:11}}>
                                            {["Monday","Tuesday","Wednesday","Thursday","Friday","Saturday","Sunday"].map(d => <option key={d} value={d}>{d}</option>)}
                                          </select>
                                        </div>
                                        <div>
                                          <div style={{fontSize:10,color:C.textMuted,marginBottom:3}}>Type</div>
                                          <select value={editingDay.data.type} onChange={e => setEditingDay(prev => ({...prev, data:{...prev.data, type:e.target.value}}))} style={{width:"100%",fontSize:11}}>
                                            {["rest","easy","recovery","tempo","intervals","long","race_pace","cross_train","strength"].map(t => <option key={t} value={t}>{t}</option>)}
                                          </select>
                                        </div>
                                        {editingDay.data.type !== "rest" && (<>
                                        <div>
                                          <div style={{fontSize:10,color:C.textMuted,marginBottom:3}}>Distance (mi)</div>
                                          <input type="number" step="0.5" value={editingDay.data.distanceMi||""} onChange={e => setEditingDay(prev => ({...prev, data:{...prev.data, distanceMi:parseFloat(e.target.value)||0}}))} style={{width:"100%",fontSize:11}} />
                                        </div>
                                        <div>
                                          <div style={{fontSize:10,color:C.textMuted,marginBottom:3}}>Target Pace</div>
                                          <input placeholder="M:SS" value={editingDay.data.targetPace||""} onChange={e => setEditingDay(prev => ({...prev, data:{...prev.data, targetPace:e.target.value||null}}))} style={{width:"100%",fontSize:11}} />
                                        </div>
                                        <div>
                                          <div style={{fontSize:10,color:C.textMuted,marginBottom:3}}>HR Zone</div>
                                          <select value={editingDay.data.hrZone||""} onChange={e => setEditingDay(prev => ({...prev, data:{...prev.data, hrZone:parseInt(e.target.value)||null}}))} style={{width:"100%",fontSize:11}}>
                                            <option value="">None</option>
                                            {[1,2,3,4,5].map(z => <option key={z} value={z}>Z{z}{hrZones?.zones?.[z-1] ? ` (${hrZones.zones[z-1].minBPM}-${hrZones.zones[z-1].maxBPM})` : ""}</option>)}
                                          </select>
                                        </div>
                                        <div>
                                          <div style={{fontSize:10,color:C.textMuted,marginBottom:3}}>Duration (min)</div>
                                          <input type="number" value={editingDay.data.durationMin||""} onChange={e => setEditingDay(prev => ({...prev, data:{...prev.data, durationMin:parseInt(e.target.value)||null}}))} style={{width:"100%",fontSize:11}} />
                                        </div>
                                        <div>
                                          <div style={{fontSize:10,color:C.textMuted,marginBottom:3}}>Environment</div>
                                          <select value={editingDay.data.indoor?"indoor":"outdoor"} onChange={e => setEditingDay(prev => ({...prev, data:{...prev.data, indoor:e.target.value==="indoor"}}))} style={{width:"100%",fontSize:11}}>
                                            <option value="outdoor">Outdoor</option>
                                            <option value="indoor">Indoor</option>
                                          </select>
                                        </div>
                                        </>)}
                                      </div>
                                      {editingDay.data.type !== "rest" && (
                                      <div>
                                        <div style={{fontSize:10,color:C.textMuted,marginBottom:3}}>Description</div>
                                        <input value={editingDay.data.description||""} onChange={e => setEditingDay(prev => ({...prev, data:{...prev.data, description:e.target.value}}))} style={{width:"100%",fontSize:11}} />
                                      </div>
                                      )}
                                      {STRUCTURED_TYPES.includes(editingDay.data.type) && (() => {
                                        const s = { ...DEFAULT_INTERVAL_SPEC, ...editingDay.data.intervalSpec };
                                        const built = s.enabled ? window.PlanMath.buildIntervalWorkout(s, { type: editingDay.data.type }) : null;
                                        const lbl = (t) => <div style={{fontSize:10,color:C.textMuted,marginBottom:3}}>{t}</div>;
                                        const inp = { width:"100%", fontSize:11 };
                                        return (
                                          <div style={{padding:"10px 12px",background:C.bg,borderRadius:8,border:`1px solid ${C.cyan}33`}}>
                                            <label style={{display:"flex",alignItems:"center",gap:8,fontSize:11,color:C.cyan,cursor:"pointer",fontWeight:700}}>
                                              <input type="checkbox" checked={!!s.enabled} onChange={e => updateSpec({ enabled: e.target.checked })} />
                                              Build from structured workout (reps · HR band · recovery)
                                            </label>
                                            {s.enabled && (<>
                                              <div style={{display:"grid",gridTemplateColumns:"1fr 1fr 1fr",gap:8,marginTop:10}}>
                                                <div>{lbl("Warm-up (mi)")}<input type="number" step="0.5" min="0" value={s.warmupMi} onChange={e => updateSpec({ warmupMi:parseFloat(e.target.value)||0 })} style={inp} /></div>
                                                <div>{lbl("Reps")}<input type="number" step="1" min="1" value={s.reps} onChange={e => updateSpec({ reps:parseInt(e.target.value)||1 })} style={inp} /></div>
                                                <div>{lbl("Cool-down (mi)")}<input type="number" step="0.5" min="0" value={s.cooldownMi} onChange={e => updateSpec({ cooldownMi:parseFloat(e.target.value)||0 })} style={inp} /></div>
                                                <div>{lbl("Work / rep")}<input type="number" step="0.5" min="0" value={s.workVal} onChange={e => updateSpec({ workVal:parseFloat(e.target.value)||0 })} style={inp} /></div>
                                                <div>{lbl("Unit")}<select value={s.workUnit} onChange={e => updateSpec({ workUnit:e.target.value })} style={inp}><option value="min">min</option><option value="mi">mi</option><option value="m">m</option></select></div>
                                                <div>{lbl("Work pace (M:SS)")}<input placeholder="optional" value={s.workPace} onChange={e => updateSpec({ workPace:e.target.value })} style={inp} /></div>
                                                <div>{lbl("Work HR low")}<input type="number" min="60" max="220" placeholder="e.g. 167" value={s.hrLow} onChange={e => updateSpec({ hrLow:e.target.value })} style={inp} /></div>
                                                <div>{lbl("Work HR high")}<input type="number" min="60" max="220" placeholder="e.g. 176" value={s.hrHigh} onChange={e => updateSpec({ hrHigh:e.target.value })} style={inp} /></div>
                                                <div>{lbl("Recovery type")}<select value={s.recJog?"jog":"stand"} onChange={e => updateSpec({ recJog:e.target.value==="jog" })} style={inp}><option value="jog">Jog</option><option value="stand">Standing</option></select></div>
                                                <div>{lbl("Recovery amount")}<input type="number" step="1" min="0" value={s.recVal} onChange={e => updateSpec({ recVal:parseFloat(e.target.value)||0 })} style={inp} /></div>
                                                <div>{lbl("Recovery unit")}<select value={s.recUnit} onChange={e => updateSpec({ recUnit:e.target.value })} style={inp}><option value="sec">sec</option><option value="min">min</option><option value="m">m</option><option value="mi">mi</option></select></div>
                                              </div>
                                              <div style={{marginTop:10,fontSize:11,lineHeight:1.5}}>
                                                {built ? (<>
                                                  <span style={{color:C.textMuted}}>Preview · </span>
                                                  <span style={{color:C.cyan}}>~{built.distanceMi}mi</span>
                                                  <span style={{color:C.textMuted}}> · type {editingDay.data.type}</span>
                                                  <div style={{color:C.text,marginTop:3}}>{built.description}</div>
                                                  {s.workUnit === "min" && !built.targetPace && (
                                                    <div style={{color:C.amber,marginTop:3,fontSize:10}}>Add a work pace for an accurate mileage total.</div>
                                                  )}
                                                </>) : <span style={{color:C.amber}}>Fill reps + work value to generate the workout.</span>}
                                              </div>
                                              <div style={{color:C.textMuted,fontSize:10,marginTop:6}}>On Save this overwrites the description, distance, target pace/HR and the watch steps for this day.</div>
                                            </>)}
                                          </div>
                                        );
                                      })()}
                                      <div style={{display:"flex",gap:8}}>
                                        <Btn small onClick={() => saveDayWithStructure(activePlan.id, wi, di, editingDay.data)}>Save</Btn>
                                        <button onClick={() => setEditingDay(null)} style={{background:"none",border:`1px solid ${C.border}`,borderRadius:6,padding:"4px 12px",color:C.textMuted,cursor:"pointer",fontSize:11,fontFamily:"'IBM Plex Mono',monospace"}}>Cancel</button>
                                      </div>
                                    </div>
                                  ) : (
                                    isOwner && <button onClick={() => setEditingDay({ wi, di, data: { ...day } })} style={{
                                      background:"none",border:`1px solid ${C.cyan}40`,borderRadius:6,padding:"5px 12px",
                                      color:C.cyan,cursor:"pointer",fontSize:11,fontFamily:"'IBM Plex Mono',monospace",alignSelf:"flex-start",
                                    }}>✏️ Edit Day</button>
                                  )}

                                  {/* Peloton settings */}
                                  {hasPeloton && (
                                    <div style={{padding:"10px 14px",background:"#60a5fa10",border:"1px solid #60a5fa30",borderRadius:8}}>
                                      <div style={{fontSize:11,color:"#60a5fa",fontWeight:700,marginBottom:8,textTransform:"uppercase",letterSpacing:"0.06em"}}>
                                        🚴 Peloton — {day.peloton.rideType}
                                      </div>
                                      <div style={{display:"flex",gap:20,flexWrap:"wrap",alignItems:"center"}}>
                                        <div>
                                          <div style={{fontSize:10,color:C.textMuted,marginBottom:2}}>Resistance</div>
                                          <div style={{fontSize:20,fontWeight:700,color:"#60a5fa",fontFamily:"'Space Grotesk',sans-serif"}}>{day.peloton.resistance}</div>
                                        </div>
                                        <div>
                                          <div style={{fontSize:10,color:C.textMuted,marginBottom:2}}>Cadence</div>
                                          <div style={{fontSize:20,fontWeight:700,color:"#60a5fa",fontFamily:"'Space Grotesk',sans-serif"}}>{day.peloton.cadence}</div>
                                        </div>
                                        {isStrength && (
                                          <span style={{fontSize:11,color:C.amber,padding:"4px 10px",background:C.amber+"15",border:`1px solid ${C.amber}40`,borderRadius:6,fontWeight:600}}>
                                            Heavy gear · Low cadence · Builds leg power
                                          </span>
                                        )}
                                        {day.durationMin && (
                                          <div>
                                            <div style={{fontSize:10,color:C.textMuted,marginBottom:2}}>Duration</div>
                                            <div style={{fontSize:20,fontWeight:700,color:C.textDim,fontFamily:"'Space Grotesk',sans-serif"}}>{day.durationMin} <span style={{fontSize:12}}>min</span></div>
                                          </div>
                                        )}
                                      </div>
                                    </div>
                                  )}

                                  {/* Nutrition */}
                                  {hasNutrition && (
                                    <div style={{display:"grid",gridTemplateColumns:"1fr 1fr",gap:8}}>
                                      {(() => {
                                        const parts = typeof day.nutrition === "string"
                                          ? { pre: day.nutrition.match(/pre[:\s]+([^.]+\.?)/i)?.[1]?.trim(), post: day.nutrition.match(/post[:\s]+([^.]+\.?)/i)?.[1]?.trim(), during: day.nutrition.match(/during[:\s]+([^.]+\.?)/i)?.[1]?.trim() }
                                          : day.nutrition;
                                        return (
                                          <>
                                            {(parts.pre || typeof day.nutrition === "string") && (
                                              <div style={{padding:"10px 12px",background:C.green+"10",border:`1px solid ${C.green}30`,borderRadius:8}}>
                                                <div style={{fontSize:10,color:C.green,fontWeight:700,marginBottom:5}}>PRE-WORKOUT</div>
                                                <div style={{fontSize:12,color:C.textDim,lineHeight:1.6}}>{parts.pre || day.nutrition}</div>
                                              </div>
                                            )}
                                            {parts.post && (
                                              <div style={{padding:"10px 12px",background:C.cyan+"10",border:`1px solid ${C.cyan}30`,borderRadius:8}}>
                                                <div style={{fontSize:10,color:C.cyan,fontWeight:700,marginBottom:5}}>POST-WORKOUT</div>
                                                <div style={{fontSize:12,color:C.textDim,lineHeight:1.6}}>{parts.post}</div>
                                              </div>
                                            )}
                                            {parts.during && (
                                              <div style={{padding:"10px 12px",background:C.amber+"10",border:`1px solid ${C.amber}30`,borderRadius:8,gridColumn:"1/-1"}}>
                                                <div style={{fontSize:10,color:C.amber,fontWeight:700,marginBottom:5}}>DURING</div>
                                                <div style={{fontSize:12,color:C.textDim,lineHeight:1.6}}>{parts.during}</div>
                                              </div>
                                            )}
                                          </>
                                        );
                                      })()}
                                    </div>
                                  )}

                                  {/* Treadmill — structured incline+pace workout for indoor runs */}
                                  {hasTreadmill && (
                                    <div style={{padding:"10px 14px",background:C.amber+"10",border:`1px solid ${C.amber}30`,borderRadius:8}}>
                                      <div style={{fontSize:11,color:C.amber,fontWeight:700,marginBottom:8,textTransform:"uppercase",letterSpacing:"0.06em"}}>
                                        🏃 Treadmill · base incline {day.treadmill.inclinePct}%
                                      </div>
                                      {Array.isArray(day.treadmill.segments) && day.treadmill.segments.length > 0 ? (
                                        <div style={{display:"flex",flexDirection:"column",gap:6}}>
                                          {day.treadmill.segments.map((seg, si) => (
                                            <div key={si} style={{display:"grid",gridTemplateColumns:"minmax(80px,1fr) auto auto auto",gap:10,alignItems:"center",fontSize:12,color:C.textDim,lineHeight:1.4}}>
                                              <span style={{fontWeight:600,color:C.text}}>{seg.label || "Segment"}</span>
                                              <span>{seg.durationMin ? `${seg.durationMin} min` : seg.distanceMi ? `${seg.distanceMi} mi` : ""}</span>
                                              <span style={{fontFamily:"'IBM Plex Mono',monospace",color:C.amber}}>{seg.inclinePct}% incline</span>
                                              <span style={{fontFamily:"'IBM Plex Mono',monospace"}}>{seg.pace ? `@ ${seg.pace}/mi` : ""}</span>
                                            </div>
                                          ))}
                                        </div>
                                      ) : (
                                        <div style={{fontSize:12,color:C.textDim}}>Hold a steady {day.treadmill.inclinePct}% incline for the full run.</div>
                                      )}
                                    </div>
                                  )}

                                  {/* No extra details */}
                                  {!hasPeloton && !hasTreadmill && !hasNutrition && (
                                    <div style={{fontSize:12,color:C.textMuted}}>{planText(day.description)}</div>
                                  )}

                                  {/* Athlete note for the coach — free-form, surfaced to
                                      Coach Analysis via formatActivityPlanContext. */}
                                  {!isStrength && day.type !== "rest" && (
                                    <div style={{marginTop:4}}>
                                      <div style={{fontSize:10,color:C.textMuted,fontWeight:700,textTransform:"uppercase",letterSpacing:"0.06em",marginBottom:4}}>
                                        💬 Note for coach
                                      </div>
                                      <textarea
                                        key={day.athleteNote || ""}
                                        defaultValue={day.athleteNote || ""}
                                        placeholder={isOwner ? "How did it feel? Anything the coach should know (leg tightness, ran with friend, equipment swap…)" : "No note for this day."}
                                        readOnly={!isOwner}
                                        onClick={(e) => e.stopPropagation()}
                                        onBlur={(e) => {
                                          if (!isOwner) return;
                                          const next = e.target.value.trim();
                                          const prior = (day.athleteNote || "").trim();
                                          if (next !== prior) saveAthleteNote(activePlan.id, wi, di, next);
                                        }}
                                        style={{
                                          width:"100%", minHeight:46, resize:"vertical",
                                          background:C.bg2, color:C.text,
                                          border:`1px solid ${C.border}`, borderRadius:6,
                                          padding:"6px 8px", fontSize:12, lineHeight:1.4,
                                          fontFamily:"inherit", boxSizing:"border-box",
                                        }}
                                      />
                                    </div>
                                  )}
                                </div>
                              )}
                              </div>
                            );
                          });
                        })()}

                        {/* Week summary bar */}
                        {(() => {
                          const days = week.days || [];
                          const completedCount = days.filter(d => d.completed).length;
                          const skippedCount = days.filter(d => d.skipped).length;
                          const totalDays = days.filter(d => d.type !== "rest").length;
                          return (
                            <div style={{ display:"flex", justifyContent:"space-between", alignItems:"center", marginTop:10, padding:"8px 12px", background:C.bg2, borderRadius:8 }}>
                              <div style={{ display:"flex", alignItems:"center", gap:8 }}>
                                <span style={{ fontSize:11, color: completedCount === totalDays && totalDays > 0 ? C.green : C.textMuted }}>
                                  {completedCount}/{totalDays} done{skippedCount > 0 ? `, ${skippedCount} skipped` : ""}
                                </span>
                                {totalDays > 0 && (
                                  <div style={{ width:60, height:4, background:C.bg3, borderRadius:2, overflow:"hidden" }}>
                                    <div style={{ width:`${totalDays > 0 ? (completedCount/totalDays*100) : 0}%`, height:"100%", background: completedCount === totalDays ? C.green : C.cyan, borderRadius:2, transition:"width 0.3s" }} />
                                  </div>
                                )}
                              </div>
                              <button
                                onClick={(e) => { e.stopPropagation(); suggestHarderSession(wi); }}
                                disabled={generatingDay === `suggest-${wi}`}
                                style={{ background:"none", border:`1px solid ${C.amber}40`, borderRadius:6, padding:"4px 10px", cursor:"pointer", fontSize:10, color:C.amber, fontFamily:"'IBM Plex Mono',monospace" }}>
                                {generatingDay === `suggest-${wi}` ? "Thinking…" : "⚡ Suggest harder session"}
                              </button>
                            </div>
                          );
                        })()}
                      </div>
                    )}
                  </Card>
                  </React.Fragment>
                );
              });
              })()}
            </div>
          )}

          {/* Past plans */}
          {plans.filter(p => p.id !== activePlan?.id).length > 0 && (
            <Card>
              <div style={{fontSize:13,fontWeight:700,color:C.textMuted,marginBottom:8}}>Previous Plans</div>
              {plans.filter(p => p.id !== activePlan?.id).map(p => (
                <div key={p.id} style={{display:"flex",justifyContent:"space-between",alignItems:"center",padding:"8px 0",borderBottom:`1px solid ${C.border}30`}}>
                  <div>
                    <span style={{fontSize:13,color:C.text}}>{p.raceName}</span>
                    <span style={{fontSize:11,color:C.textMuted,marginLeft:8}}>{p.raceDistance} — {p.raceDate}</span>
                  </div>
                  <div style={{display:"flex",gap:8}}>
                    <button onClick={() => setActivePlan(p)} style={{background:"none",border:`1px solid ${C.cyan}40`,borderRadius:4,padding:"2px 8px",color:C.cyan,cursor:"pointer",fontSize:11}}>View</button>
                    {isOwner && <button onClick={() => deletePlan(p.id)} style={{background:"none",border:`1px solid ${C.red}40`,borderRadius:4,padding:"2px 8px",color:C.red,cursor:"pointer",fontSize:11}}>Del</button>}
                  </div>
                </div>
              ))}
            </Card>
          )}
        </div>
      );
    }

    // ─────────────────────────────────────────────────────────────────────────
    // COACH VIEW + CHAT SIDEBAR — extracted to src/coach.jsx (module-split slice 7)
    // ─────────────────────────────────────────────────────────────────────────

    // ─────────────────────────────────────────────────────────────────────────
    // HEALTH VIEW — extracted to src/health.jsx (module-split slice 10)
    // ─────────────────────────────────────────────────────────────────────────

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