// running/src/training.jsx — slice 15 of the module split.
//
// The TRAINING tab shell: the activity log/calendar/form sub-tabs plus
// the tab router that renders Plan / Roadmap / Rules / Race / History /
// Performance / Analytics — all of which are earlier slices, so this
// file destructures them from window.AppViews at eval time (it loads
// after them in document order). Extracted verbatim from
// running/index.html.

const { C, Card, Stat, Badge, TabBar, EmptyState, ChartTooltip, fmt } = window.SharedUI;
const useData = () => (window.__appUseData ? window.__appUseData() : null);
const { actCat, actDisplay, isRun, isXT, effectiveType } = window.ActivityTypes;
const { Plan, PlanRules, Roadmap, Races, GarminHistory, PerformanceView, Analytics } = window.AppViews;
const { useState, useEffect, useMemo } = React;
const {
  AreaChart, Area, LineChart, Line, Bar, ComposedChart,
  XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
  ReferenceLine, ScatterChart, Scatter,
} = Recharts;

    // ── Athlete log — per-activity fueling + free-text notes for the coach.
    // Persisted on the activity doc (athleteFueling / athleteNotes). Read by
    // the workout-insight prompts and the chat context; the race-fueling
    // planner will mine athleteFueling as the gut-training history. Keyed
    // by activity id at the call site so drafts reset per activity.
    function AthleteLogEditor({ userId, activity, isOwner }) {
      const [base, setBase] = useState({ fueling: activity.athleteFueling || "", notes: activity.athleteNotes || "" });
      const [fueling, setFueling] = useState(base.fueling);
      const [notes, setNotes] = useState(base.notes);
      const [saveState, setSaveState] = useState(null); // null | "saving" | "saved"
      const dirty = fueling !== base.fueling || notes !== base.notes;
      const save = () => {
        if (!userId || !activity.id) return;
        setSaveState("saving");
        db.collection("users").doc(userId).collection("activities").doc(String(activity.id))
          .set({ athleteFueling: fueling.trim() || null, athleteNotes: notes.trim() || null }, { merge: true })
          .then(() => { setBase({ fueling: fueling.trim(), notes: notes.trim() }); setSaveState("saved"); setTimeout(() => setSaveState(null), 2000); })
          .catch(() => setSaveState(null));
      };
      if (!isOwner && !base.fueling && !base.notes) return null;
      const ta = (value, onChange, placeholder) => isOwner ? (
        <textarea value={value} onChange={e => onChange(e.target.value)} placeholder={placeholder} rows={2} style={{
          width: "100%", boxSizing: "border-box", background: C.bg2, border: `1px solid ${C.border}`,
          borderRadius: 8, padding: "8px 10px", color: C.text, fontSize: 12, lineHeight: 1.5,
          fontFamily: "inherit", resize: "vertical",
        }} />
      ) : (
        <div style={{ fontSize: 12, color: C.textDim, lineHeight: 1.5, whiteSpace: "pre-wrap" }}>{value}</div>
      );
      return (
        <div style={{ marginTop: 12, padding: "12px 16px", background: `${C.cyan}08`, borderRadius: 10, border: `1px solid ${C.cyan}20` }}>
          <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 10 }}>
            <div style={{ fontSize: 11, fontWeight: 700, color: C.cyan, textTransform: "uppercase", letterSpacing: 1 }}>Athlete Log</div>
            {isOwner && (dirty || saveState) && (
              <button onClick={save} disabled={saveState === "saving" || !dirty} style={{
                background: dirty ? C.cyan : "transparent", color: dirty ? C.bg0 : C.green,
                border: "none", borderRadius: 6, padding: "3px 12px", fontSize: 11, fontWeight: 700,
                cursor: dirty ? "pointer" : "default", fontFamily: "'IBM Plex Mono',monospace",
              }}>{saveState === "saving" ? "Saving…" : saveState === "saved" ? "Saved ✓" : "Save"}</button>
            )}
          </div>
          <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
            {(isOwner || base.fueling) && (
              <div>
                <div style={{ fontSize: 10, color: C.textMuted, marginBottom: 4, textTransform: "uppercase", letterSpacing: "0.05em" }}>Fueling used</div>
                {ta(fueling, setFueling, "e.g. 2 gels (25min, 55min) + 500ml electrolyte — sat fine until mile 10, then sloshing")}
              </div>
            )}
            {(isOwner || base.notes) && (
              <div>
                <div style={{ fontSize: 10, color: C.textMuted, marginBottom: 4, textTransform: "uppercase", letterSpacing: "0.05em" }}>Notes for the coach</div>
                {ta(notes, setNotes, "anything the data can't show — how it felt, niggles, sleep, stress, course, why you cut it short…")}
              </div>
            )}
          </div>
        </div>
      );
    }

    function Training({ activities, userId, isOwner, hrZoneConfig, initialTab }) {
      const { nutritionHistory, activePlan, gearList, addShoe, setActivityShoe, setActivityType } = useData();
      // linkedActivityId → structured plan day, for per-rep slice grading.
      const planDayByActId = useMemo(() => {
        const out = {};
        (activePlan?.weeks || []).forEach(w => (w?.days || []).forEach(d => {
          if (d && d.linkedActivityId) out[String(d.linkedActivityId)] = d;
        }));
        return out;
      }, [activePlan]);
      const [tab, setTab] = useState(initialTab || "Plan");
      const [selected, setSelected] = useState(null);
      const [multiSelected, setMultiSelected] = useState(new Set()); // IDs checked for bulk ops
      const [merging, setMerging] = useState(false);
      const [mergeResult, setMergeResult] = useState(null);
      const [actFilter, setActFilter] = useState("all"); // all | runs | xt | duplicates
      const [deleting, setDeleting] = useState(null);
      const [lapData, setLapData] = useState({}); // { activityId: [laps] }
      const [streamData, setStreamData] = useState({}); // { activityId: [{t, d, hr, v, alt, cad, grade}] }
      const [sliceData, setSliceData] = useState({}); // { activityId: { source, slices, rollup } } — server-persisted slices
      // MP anchor for slicing a workout's segments client-side when the
      // server didn't persist slices (older detail docs). Current-fitness
      // MP from recent runs — good enough to colour the zone bands; the
      // server's persisted slices (goal-aware) win when present.
      const sliceMp = useMemo(() => {
        try {
          const runs = (activities || []).filter(a => isRun(a));
          const vo2 = window.RaceMath.calcEffectiveVO2max(runs);
          const tp = vo2 ? window.RaceMath.trainingPaceMidpoints(vo2, runs) : null;
          return tp?.mp || null;
        } catch (_) { return null; }
      }, [activities]);
      const [loadingLaps, setLoadingLaps] = useState(null);
      // Per-workout shoe picker: inline "add new shoe" form toggle + name buffer.
      const [addingShoe, setAddingShoe] = useState(false);
      const [newShoeName, setNewShoeName] = useState("");
      // Manual interval-pace overrides. { actId: { sliceIdx: paceSec } } — let
      // the athlete correct a rep's actual pace (treadmill belt drift, GPS
      // tunnels, etc.); these feed both the display and the plan grading.
      // Persisted under activities/{id}/detail/laps.paceOverrides.
      const [paceOverrides, setPaceOverrides] = useState({});
      const [editingRep, setEditingRep] = useState(null); // { actId, idx } | null
      const [repPaceBuf, setRepPaceBuf] = useState("");
      const [insightData, setInsightData] = useState({}); // { activityId: insight }
      const [loadingInsight, setLoadingInsight] = useState(null);
      const [nutritionByDate, setNutritionByDate] = useState({}); // { "YYYY-MM-DD": totals }
      // Cached lap splits for recent runs — used by the Form-vs-Pace
      // scatters to surface the fast work (strides/intervals). Whole-run
      // averages of those sessions land in easy/steady because of warmups
      // & recovery jogs, so without lap detail the cadence/HR scatters
      // show a blank tempo/race zone. null = fetch in flight; [] = no
      // stored splits for that activity.
      const [qualityLaps, setQualityLaps] = useState({}); // { actId: laps[] | null }
      const [qualityStreams, setQualityStreams] = useState({}); // { actId: streams[] } — Strava per-second samples (pace/HR/cadence) for 0.25mi blocking
      const [qualityDynamics, setQualityDynamics] = useState({}); // { actId: streams[] } — Garmin per-second running-dynamics samples

      const toggleMultiSelect = (actId, e) => {
        e.stopPropagation();
        setMultiSelected(prev => {
          const next = new Set(prev);
          next.has(actId) ? next.delete(actId) : next.add(actId);
          return next;
        });
      };

      const mergeSelected = async () => {
        if (multiSelected.size < 2 || merging) return;
        setMerging(true); setMergeResult(null);
        try {
          const fn = functions.httpsCallable("mergeDuplicateActivities");
          const ids = [...multiSelected];
          const res = await fn({ dryRun: false, activityIds: ids });
          setMergeResult({ success: true, msg: `Merged ${res.data.merged ?? 1} activit${(res.data.merged ?? 1) !== 1 ? "ies" : "y"}` });
          setMultiSelected(new Set());
          if (selected && multiSelected.has(selected.id)) setSelected(null);
        } catch (err) {
          setMergeResult({ success: false, msg: err.message || "Merge failed" });
        }
        setMerging(false);
      };

      // Concatenate (sequential join) — for a single run split into pt 1 / pt 2
      // because the treadmill or watch cut out mid-session. Distances and
      // times sum; pt 2's stream is stitched onto the end of pt 1.
      const concatSelected = async () => {
        if (multiSelected.size !== 2 || merging) return;
        setMerging(true); setMergeResult(null);
        try {
          const fn = functions.httpsCallable("concatenateActivities");
          const ids = [...multiSelected];
          const res = await fn({ dryRun: false, activityIds: ids });
          const mi = res.data.distance ? (res.data.distance / 1609.34).toFixed(2) : "?";
          setMergeResult({ success: true, msg: `Joined into "${res.data.name}" — ${mi} mi` });
          setMultiSelected(new Set());
          if (selected && multiSelected.has(selected.id)) setSelected(null);
        } catch (err) {
          setMergeResult({ success: false, msg: err.message || "Concatenate failed" });
        }
        setMerging(false);
      };

      // Newest-first. Read the date robustly: start_date may be a Firestore
      // Timestamp (.toDate/.seconds) or a plain ISO string/Date depending on
      // how the activity was synced, so `.seconds` alone is unreliable and
      // would leave the list in arrival order (oldest-first). Copy before
      // sorting to avoid mutating the prop array in place.
      const allSorted = useMemo(() => {
        const ms = (a) => {
          const d = a.start_date?.toDate ? a.start_date.toDate() : new Date(a.start_date);
          return d.getTime();
        };
        return [...activities].sort((a, b) => ms(b) - ms(a));
      }, [activities]);

      // Detect duplicate activities (same name + date + distance within 1%)
      const duplicates = useMemo(() => {
        const dupes = new Set();
        for (let i = 0; i < allSorted.length; i++) {
          for (let j = i + 1; j < allSorted.length; j++) {
            const a = allSorted[i], b = allSorted[j];
            const aDate = a.start_date?.toDate ? a.start_date.toDate() : new Date(a.start_date);
            const bDate = b.start_date?.toDate ? b.start_date.toDate() : new Date(b.start_date);
            const sameDay = aDate.toDateString() === bDate.toDateString();
            const sameName = a.name === b.name;
            const distDiff = a.distance && b.distance ? Math.abs(a.distance - b.distance) / Math.max(a.distance, b.distance) : 1;
            const timeDiff = a.moving_time && b.moving_time ? Math.abs(a.moving_time - b.moving_time) : 9999;
            if (sameDay && (sameName || (distDiff < 0.02 && timeDiff < 30))) {
              dupes.add(a.id);
              dupes.add(b.id);
            }
          }
        }
        return dupes;
      }, [allSorted]);

      // Auto-merge: run mergeDuplicateActivities when confirmed by user
      const [autoMergePrompted, setAutoMergePrompted] = useState(false);
      const [autoMerging, setAutoMerging] = useState(false);

      const runAutoMerge = async () => {
        setAutoMerging(true);
        try {
          const fn = functions.httpsCallable("mergeDuplicateActivities");
          const res = await fn({ dryRun: false });
          setMergeResult({ success: true, msg: `Auto-merged ${res.data.merged ?? 0} duplicate pair${(res.data.merged ?? 0) !== 1 ? "s" : ""}` });
        } catch (err) {
          setMergeResult({ success: false, msg: err.message || "Auto-merge failed" });
        }
        setAutoMerging(false);
        setAutoMergePrompted(true);
      };

      const deleteActivity = async (actId) => {
        if (!userId || !actId) return;
        setDeleting(actId);
        try {
          await db.collection("users").doc(userId).collection("activities").doc(String(actId)).delete();
          if (selected && selected.id === actId) setSelected(null);
        } catch (err) {
          console.error("Delete failed:", err);
        }
        setDeleting(null);
      };

      // Fetch detailed lap data from Strava for selected activity
      const fetchLaps = async (actId) => {
        if (lapData[actId] || loadingLaps === actId) return;
        setLoadingLaps(actId);
        try {
          // Check if laps already stored in Firestore
          const lapDoc = await db.collection("users").doc(userId).collection("activities").doc(String(actId)).collection("detail").doc("laps").get();
          // Hydrate any manual interval-pace overrides regardless of how
          // the splits themselves resolve (stored vs server-fetched).
          const _po = lapDoc.exists ? (lapDoc.data().paceOverrides || null) : null;
          if (_po && Object.keys(_po).length) setPaceOverrides(prev => ({ ...prev, [actId]: _po }));
          if (lapDoc.exists && lapDoc.data().streams && lapDoc.data().streams.length > 0) {
            setLapData(prev => ({ ...prev, [actId]: lapDoc.data().laps || [] }));
            setStreamData(prev => ({ ...prev, [actId]: lapDoc.data().streams || [] }));
            if (lapDoc.data().slices) setSliceData(prev => ({ ...prev, [actId]: lapDoc.data().slices }));
          } else {
            // Fetch from Strava API via cloud function (fetches laps + streams + slices)
            const fn = functions.httpsCallable("fetchActivityDetail");
            const result = await fn({ activityId: actId });
            if (result.data) {
              if (result.data.laps) setLapData(prev => ({ ...prev, [actId]: result.data.laps }));
              if (result.data.streams) setStreamData(prev => ({ ...prev, [actId]: result.data.streams }));
              if (result.data.slices) setSliceData(prev => ({ ...prev, [actId]: result.data.slices }));
            }
          }
        } catch (err) {
          console.warn("Failed to fetch laps:", err.message);
        }
        setLoadingLaps(null);
      };

      // ── Interval pace override helpers ──────────────────────────────────
      // MM:SS → seconds ("6:42" → 402). null on malformed input.
      const parsePaceMSS = (str) => {
        const m = String(str || "").trim().match(/^(\d{1,2}):([0-5]?\d)$/);
        return m ? (+m[1]) * 60 + (+m[2]) : null;
      };
      const fmtPaceMSS = (s) => Number.isFinite(s)
        ? `${Math.floor(Math.round(s) / 60)}:${String(Math.round(s) % 60).padStart(2, "0")}` : "";
      // Persist (or clear, with paceSec=null) a manual pace for one slice.
      // Optimistic local update + Firestore merge keyed by slice idx.
      const saveRepPace = (actId, idx, paceSec) => {
        setPaceOverrides(prev => {
          const cur = { ...(prev[actId] || {}) };
          if (paceSec == null) delete cur[idx]; else cur[idx] = paceSec;
          return { ...prev, [actId]: cur };
        });
        db.collection("users").doc(userId).collection("activities").doc(String(actId))
          .collection("detail").doc("laps")
          .set({ paceOverrides: { [idx]: paceSec == null
            ? firebase.firestore.FieldValue.delete() : paceSec } }, { merge: true })
          .catch(e => console.warn("saveRepPace failed:", e.message));
      };

      // When the Form tab is active, lazily pull the *cached* lap-splits
      // doc (detail/laps) for the ~25 most recent runs so the Form-vs-Pace
      // scatters can surface strides/intervals (whose whole-run averages
      // land in easy/steady). Never calls Strava — just reads Firestore;
      // runs with no stored splits resolve to []. Guarded so each id is
      // fetched at most once per session.
      useEffect(() => {
        if (tab !== "Form" || !userId) return;
        // Own the sort: pick the newest 25 runs regardless of how
        // `allSorted` happens to be ordered upstream (a shape change to
        // start_date broke the Timestamp.seconds comparator and left
        // allSorted in arrival order, which sometimes meant the lazy
        // fetcher grabbed the oldest 25 runs and the Form-vs-Pace
        // scatters lost all their fine-grained lap/block dots).
        const lazyDateMs = (a) => {
          const s = a?.start_date;
          if (!s) return 0;
          if (typeof s.toDate === "function") return s.toDate().getTime();
          if (typeof s.seconds === "number") return s.seconds * 1000;
          const t = Date.parse(s);
          return Number.isFinite(t) ? t : 0;
        };
        const candidates = allSorted
          .filter(isRun)
          .slice()
          .sort((a, b) => lazyDateMs(b) - lazyDateMs(a))
          .slice(0, 25);
        candidates.forEach(a => {
          const id = String(a.id);
          if (qualityLaps[id] !== undefined) return;
          setQualityLaps(prev => prev[id] !== undefined ? prev : { ...prev, [id]: null }); // mark in-flight
          const detailRef = db.collection("users").doc(userId).collection("activities").doc(id).collection("detail");
          Promise.all([detailRef.doc("laps").get(), detailRef.doc("dynamics").get()])
            .then(([lapsDoc, dynDoc]) => {
              const d = lapsDoc.exists ? (lapsDoc.data() || {}) : {};
              setQualityLaps(prev => ({ ...prev, [id]: Array.isArray(d.laps) ? d.laps : [] }));
              setQualityStreams(prev => ({ ...prev, [id]: Array.isArray(d.streams) ? d.streams : [] }));
              const dd = dynDoc.exists ? (dynDoc.data() || {}) : {};
              setQualityDynamics(prev => ({ ...prev, [id]: Array.isArray(dd.streams) ? dd.streams : [] }));
            })
            .catch(() => {
              setQualityLaps(prev => ({ ...prev, [id]: [] }));
              setQualityStreams(prev => ({ ...prev, [id]: [] }));
              setQualityDynamics(prev => ({ ...prev, [id]: [] }));
            });
        });
      }, [tab, userId, allSorted]);

      // Load AI insight for an activity
      const loadInsight = async (actId, forceGenerate = false) => {
        if (insightData[actId] && insightData[actId].status === "complete" && !forceGenerate) return;
        if (loadingInsight === actId && !forceGenerate) return;
        setLoadingInsight(actId);
        try {
          // First check Firestore for existing insight
          if (!forceGenerate) {
            const insightDoc = await db.collection("users").doc(userId).collection("activities")
              .doc(String(actId)).collection("detail").doc("insight").get();
            if (insightDoc.exists) {
              const data = insightDoc.data();
              if (data.status === "complete" || data.summary) {
                setInsightData(prev => ({ ...prev, [actId]: { ...data, status: "complete" } }));
                setLoadingInsight(null);
                return;
              }
              if (data.status === "generating") {
                setInsightData(prev => ({ ...prev, [actId]: { status: "generating" } }));
                let tries = 0;
                const poll = setInterval(async () => {
                  tries++;
                  const doc = await db.collection("users").doc(userId).collection("activities")
                    .doc(String(actId)).collection("detail").doc("insight").get();
                  if (doc.exists && (doc.data().status === "complete" || doc.data().summary)) {
                    clearInterval(poll);
                    setInsightData(prev => ({ ...prev, [actId]: { ...doc.data(), status: "complete" } }));
                    setLoadingInsight(null);
                  } else if (tries > 20) { clearInterval(poll); setLoadingInsight(null); }
                }, 3000);
                return;
              }
            }
          }
          setInsightData(prev => ({ ...prev, [actId]: { status: "generating" } }));
          const fn = functions.httpsCallable("generateActivityInsight");
          // Pass forceRegen so the Regenerate button actually re-runs the
          // analysis instead of returning the cached doc the backend
          // would otherwise short-circuit on.
          const result = await fn({ activityId: actId, hrZones: hrZoneConfig, forceRegen: !!forceGenerate });
          if (result.data) setInsightData(prev => ({ ...prev, [actId]: { ...result.data, status: "complete" } }));
        } catch (err) {
          console.warn("Insight load failed:", err.message);
          setInsightData(prev => ({ ...prev, [actId]: { status: "failed", error: err.message } }));
        }
        setLoadingInsight(null);
      };

      const runs = useMemo(() => allSorted.filter(a => isRun(a)), [allSorted]);
      const xtActivities = useMemo(() => allSorted.filter(a => isXT(a)), [allSorted]);

      const filteredActivities = useMemo(() => {
        if (actFilter === "runs") return runs;
        if (actFilter === "xt") return xtActivities;
        if (actFilter === "duplicates") return allSorted.filter(a => duplicates.has(a.id));
        return allSorted;
      }, [allSorted, runs, xtActivities, actFilter, duplicates]);

      // Calendar heatmap data (last 12 weeks) — includes cross-training
      const calendarData = useMemo(() => {
        const map = {};
        allSorted.forEach(r => {
          const d = r.start_date?.toDate ? r.start_date.toDate() : new Date(r.start_date);
          const key = d.toISOString().split("T")[0];
          if (!map[key]) map[key] = { miles: 0, xtMins: 0, types: new Set() };
          if (isRun(r)) map[key].miles += r.distance / 1609.34;
          else map[key].xtMins += (r.moving_time || 0) / 60;
          map[key].types.add(actCat(effectiveType(r)));
        });
        return map;
      }, [allSorted]);

      const renderCalendar = () => {
        const today = new Date();
        const days = [];
        for (let i = 83; i >= 0; i--) {
          const d = new Date(today);
          d.setDate(today.getDate() - i);
          const key = d.toISOString().split("T")[0];
          const entry = calendarData[key] || { miles: 0, xtMins: 0, types: new Set() };
          const miles = entry.miles;
          const hasXT = entry.xtMins > 0;
          const intensity = miles > 10 ? 1 : miles > 6 ? 0.7 : miles > 3 ? 0.45 : miles > 0 ? 0.25 : 0;
          const xtIntensity = entry.xtMins > 60 ? 0.8 : entry.xtMins > 30 ? 0.5 : entry.xtMins > 0 ? 0.3 : 0;
          days.push({ key, date: d, miles, intensity, hasXT, xtIntensity, xtMins: entry.xtMins });
        }

        // Group by week
        const weeks = [];
        for (let i = 0; i < days.length; i += 7) {
          weeks.push(days.slice(i, i+7));
        }

        return (
          <div style={{ overflowX:"auto" }}>
            <div style={{ display:"flex", gap: 3, minWidth: "fit-content" }}>
              {weeks.map((week, wi) => (
                <div key={wi} style={{ display:"flex", flexDirection:"column", gap: 3 }}>
                  {week.map(day => {
                    const hasRun = day.intensity > 0;
                    const bg = hasRun
                      ? `rgba(125, 211, 252, ${day.intensity})`
                      : day.hasXT ? `rgba(52, 211, 153, ${day.xtIntensity})` : C.bg3;
                    const title = hasRun
                      ? `${day.key}: ${day.miles.toFixed(1)} mi${day.hasXT ? ` + ${Math.round(day.xtMins)}min XT` : ""}`
                      : day.hasXT ? `${day.key}: ${Math.round(day.xtMins)}min cross-training` : day.key;
                    return (
                      <div
                        key={day.key}
                        title={title}
                        style={{
                          width: 14, height: 14, borderRadius: 3,
                          background: bg,
                          border: `1px solid ${day.hasXT && hasRun ? C.green + "60" : C.border}`,
                          cursor: (hasRun || day.hasXT) ? "pointer" : "default",
                        }}
                      />
                    );
                  })}
                </div>
              ))}
            </div>
            <div style={{ display:"flex", gap: 12, alignItems:"center", marginTop: 8, fontSize: 11, color: C.textMuted, flexWrap:"wrap" }}>
              <div style={{ display:"flex", gap: 4, alignItems:"center" }}>
                <span>Run:</span>
                {[0.25, 0.45, 0.7, 1].map(i => (
                  <div key={`r${i}`} style={{ width:12, height:12, borderRadius:2, background:`rgba(125,211,252,${i})`, border:`1px solid ${C.border}` }} />
                ))}
              </div>
              <div style={{ display:"flex", gap: 4, alignItems:"center" }}>
                <span>XT:</span>
                {[0.3, 0.5, 0.8].map(i => (
                  <div key={`x${i}`} style={{ width:12, height:12, borderRadius:2, background:`rgba(52,211,153,${i})`, border:`1px solid ${C.border}` }} />
                ))}
              </div>
            </div>
          </div>
        );
      };

      return (
        <div style={{ display:"flex", flexDirection:"column", gap: 20 }}>
          <div>
            <div style={{ fontFamily:"'Space Grotesk', sans-serif", fontSize: 26, fontWeight: 800, color: C.text, marginBottom: 4 }}>Training</div>
            <div style={{ color: C.textMuted, fontSize: 13 }}>
              {tab === "Plan" ? "Plan, track, and execute your training" : `${runs.length} runs · ${xtActivities.length} cross-training — synced from Strava`}
            </div>
          </div>

          <TabBar tabs={["Plan", "Roadmap", "Rules", "Race", "History", "Log", "Calendar", "Form", "Performance", "Analytics"]} active={tab} onChange={setTab} />

          {tab === "Plan" && (
            <Plan userId={userId} activities={activities} isOwner={isOwner} hrZoneConfig={hrZoneConfig} />
          )}

          {tab === "Roadmap" && (
            <Roadmap userId={userId} activities={activities} />
          )}

          {tab === "Rules" && (
            <PlanRules />
          )}

          {tab === "Race" && (
            <Races userId={userId} activities={activities} isOwner={isOwner} hrZoneConfig={hrZoneConfig} />
          )}

          {tab === "History" && (
            <GarminHistory userId={userId} />
          )}

          {tab === "Log" && (
            <div style={{ display:"flex", gap: 6 }}>
              {[
                { id: "all", label: `All (${allSorted.length})` },
                { id: "runs", label: `Runs (${runs.length})` },
                { id: "xt", label: `Cross-Training (${xtActivities.length})` },
                ...(duplicates.size > 0 ? [{ id: "duplicates", label: `Duplicates (${duplicates.size})`, warn: true }] : []),
              ].map(f => (
                <button key={f.id} onClick={() => setActFilter(f.id)}
                  style={{ padding: "4px 12px", borderRadius: 6, cursor: "pointer",
                    background: actFilter === f.id ? C.bg1 : "transparent",
                    color: actFilter === f.id ? (f.warn ? C.amber : C.cyan) : (f.warn ? C.amber : C.textMuted),
                    fontSize: 11, fontWeight: 600, fontFamily: "'IBM Plex Mono', monospace",
                    border: `1px solid ${actFilter === f.id ? (f.warn ? C.amber : C.cyan) + "40" : C.border}`,
                  }}>{f.label}</button>
              ))}
            </div>
          )}

          {/* Multi-select action bar */}
          {tab === "Log" && multiSelected.size >= 2 && (
            <div style={{ display:"flex", alignItems:"center", gap: 12, padding: "10px 16px", background: C.bg2, borderRadius: 8, border: `1px solid ${C.cyan}30` }}>
              <span style={{ fontSize: 12, color: C.cyan, fontWeight: 600 }}>{multiSelected.size} activities selected</span>
              <button onClick={mergeSelected} disabled={merging}
                style={{ padding: "6px 16px", borderRadius: 6, border: `1px solid ${C.amber}50`, background: C.amber + "15",
                  color: C.amber, fontSize: 12, fontWeight: 700, cursor: merging ? "wait" : "pointer" }}>
                {merging ? "Merging…" : "⊕ Merge Selected"}
              </button>
              {multiSelected.size === 2 && (
                <button onClick={concatSelected} disabled={merging}
                  title="Join two parts of one run (e.g. pt 1 + pt 2 after the tread cut out) end-to-end into a single activity"
                  style={{ padding: "6px 16px", borderRadius: 6, border: `1px solid ${C.cyan}50`, background: C.cyan + "15",
                    color: C.cyan, fontSize: 12, fontWeight: 700, cursor: merging ? "wait" : "pointer" }}>
                  {merging ? "Joining…" : "⛓ Concatenate Parts"}
                </button>
              )}
              <button onClick={() => setMultiSelected(new Set())}
                style={{ padding: "4px 10px", borderRadius: 6, border: `1px solid ${C.border}`, background: "transparent",
                  color: C.textMuted, fontSize: 11, cursor: "pointer" }}>Clear</button>
              {mergeResult && (
                <span style={{ fontSize: 11, color: mergeResult.success ? C.green : C.red }}>{mergeResult.msg}</span>
              )}
            </div>
          )}

          {tab === "Calendar" && (
            <Card>
              <div style={{ fontSize: 13, fontWeight: 600, color: C.textDim, marginBottom: 16 }}>Activity Heatmap — Last 12 Weeks</div>
              {renderCalendar()}
            </Card>
          )}

          {tab === "Performance" && <PerformanceView />}

          {tab === "Analytics" && (
            <Analytics activities={activities} hrZoneConfig={hrZoneConfig} userId={userId} isOwner={isOwner} />
          )}

          {tab === "Form" && (() => {
            // Extract runs with Garmin dynamics, sorted oldest→newest for trend lines.
            // Activity dynamics live under either `a.dynamics` (current shape)
            // or `a.runalyze` (legacy backward-compat shape — both are
            // dual-written in functions/index.js, but older activities synced
            // before the dual-write only have one). Read whichever is
            // populated so the Form tab doesn't show empty when only the
            // legacy shape is present. Also include verticalRatio +
            // trainingEffect + groundContactBalance in the "has anything?"
            // check — runs may have these without GCT/cadence/etc.
            const dynOf = (a) => a?.dynamics || a?.runalyze || null;
            // Cadence normalized to steps-per-minute (both feet, ~150-200).
            // Garmin running-dynamics `cadenceSpm` is already spm. Strava's
            // per-activity `average_cadence` for runs is RPM (one foot,
            // ~75-100) — double it. The <120 guard means an already-spm
            // value can never be doubled by mistake (running cadence floors
            // around ~150 spm; ~88 is unambiguously one-foot RPM).
            const cadenceSpmOf = (a) => {
              const dyn = dynOf(a)?.cadenceSpm;
              let raw = (dyn != null && dyn > 0) ? dyn : (a?.average_cadence > 0 ? a.average_cadence : null);
              if (raw == null) return null;
              // Runalyze (primary ingest since Jun '26) returns cadence ×10
              // for some runs — ~1780 instead of 178. No human runs above
              // ~250 spm, so fold an impossible reading back by 10 on read,
              // restoring corrupted history without a re-sync.
              if (raw > 250) raw = raw / 10;
              return Math.round(raw < 120 ? raw * 2 : raw);
            };
            // Runalyze's API returns vertical oscillation / ratio an order of
            // magnitude high (mm not cm; ratio ×10) for runs synced after it
            // became the primary ingest — an impossible 85cm bounce on the
            // chart. Fold any physically impossible reading back by 10 on read
            // so the corrupted history renders correctly without a backfill.
            // Real runs (VO 5-13cm, VR 4-14%) never trip these guards.
            const normVO = v => (v != null && v > 25) ? v / 10 : v;
            const normVR = v => (v != null && v > 30) ? v / 10 : v;
            const hasAnyField = (d) => d && (d.groundContactTime || d.cadenceSpm || d.verticalOscillation || d.verticalRatio || d.power || d.trainingEffect || d.groundContactBalance);
            // Form-chart quality gate. Running dynamics from a walking
            // or walk-jog activity look nothing like a real run —
            // GCT inflates, VO/VR collapse, cadence drops — and a
            // single such session lands as an off-the-chart outlier
            // that swamps the trend lines. Require at least a mile and
            // a moving pace faster than 12:00/mi so walk-paced
            // sessions (and the Mar 15 / Garmin-shakeout style entries)
            // never get charted as running form.
            const isFormQualityRun = (a) => {
              if (!a || !a.distance || !a.moving_time) return false;
              if (a.distance < 1609.34) return false; // ≥ 1 mile
              const paceSec = a.moving_time / (a.distance / 1609.34);
              return Number.isFinite(paceSec) && paceSec < 720; // faster than 12:00/mi
            };
            // Sort robustly ascending (oldest first) regardless of how
            // `allSorted` happens to be ordered upstream — the old code
            // assumed allSorted was newest-first and relied on
            // .slice(0,60).reverse() to flip it, but a shape change to
            // start_date (Timestamp → ISO string) broke the `.seconds`
            // comparator and left runsWithDynamics in whatever order
            // activities arrived. Result: the form charts started
            // reading newest → oldest left-to-right. Owning the sort
            // and slicing the LAST 60 makes the order self-evident.
            const dateMs = (a) => {
              const s = a?.start_date;
              if (!s) return 0;
              if (typeof s.toDate === "function") return s.toDate().getTime();
              if (typeof s.seconds === "number") return s.seconds * 1000;
              const t = Date.parse(s);
              return Number.isFinite(t) ? t : 0;
            };
            const runsWithDynamics = allSorted
              .filter(a => isRun(a) && hasAnyField(dynOf(a)) && isFormQualityRun(a))
              .slice()
              .sort((a, b) => dateMs(a) - dateMs(b))
              .slice(-60);   // newest 60, already oldest-first for charts

            // Repair FIT-native unit drift. Older imports stored three
            // running-dynamics fields at raw FIT scale: stride length in cm
            // (~115 instead of ~1.15m), ground-contact balance ×100 (~5005
            // instead of ~50.05%), and recovery time in minutes (~3423 instead
            // of ~57h). Fold any out-of-band reading back on read — idempotent
            // because real values (stride <5m, balance <100%, recovery <200h)
            // never trip these guards.
            for (const a of runsWithDynamics) {
              const d = dynOf(a);
              if (!d) continue;
              if (d.strideLengthSensor > 5) d.strideLengthSensor = parseFloat((d.strideLengthSensor / 100).toFixed(2));
              if (d.groundContactBalance > 100) d.groundContactBalance = Math.round(d.groundContactBalance) / 100;
              if (d.recoveryTime > 200) d.recoveryTime = Math.round(d.recoveryTime / 60);
            }

            if (runsWithDynamics.length === 0) {
              const totalRuns = allSorted.filter(isRun).length;
              return (
                <Card>
                  <EmptyState icon="📊" title="No dynamics data yet"
                    sub={totalRuns > 0
                      ? `${totalRuns} runs synced but none carry Garmin running dynamics (cadence / GCT / vert osc / power). Run the Garmin Connect bookmarklet from Settings, or pair your watch to Strava with run-dynamics enabled.`
                      : "No runs synced yet — connect Strava or run a Garmin import first."} />
                </Card>
              );
            }

            const chartDate = d => {
              const dt = d.start_date?.toDate ? d.start_date.toDate() : new Date(d.start_date);
              return dt.toLocaleDateString("en-US", { month: "short", day: "numeric" });
            };

            // Pace context for each run — every chart point gets a
            // `paceZone` that the dot renderer uses to colour by effort
            // (green=easy, cyan=steady, amber=tempo, red=race). This
            // brings the same pace-bucket comparison the Form-vs-Pace
            // scatter charts at the bottom of the tab use, into every
            // time-series at the top — so a high-GCT outlier reads
            // immediately as "ok, that was an easy run" instead of
            // "form regressed."
            const M_PER_MI = 1609.34;
            const paceSecOf = a => (a.distance > 0 && a.moving_time > 0)
              ? a.moving_time / (a.distance / M_PER_MI) : null;
            const zoneOfPace = sec => !sec ? null : sec > 540 ? "easy" : sec > 480 ? "steady" : sec > 420 ? "tempo" : "race";
            const ZONE_COLOR = { easy: C.green, steady: C.cyan, tempo: C.amber, race: C.red };
            const paceMeta = a => {
              const ps = paceSecOf(a);
              return { paceSec: ps, paceZone: zoneOfPace(ps) };
            };

            // Build chart datasets — read via dynOf so legacy-shape activities work too
            const gctData   = runsWithDynamics.filter(a => dynOf(a).groundContactTime).map(a => ({ date: chartDate(a), value: Math.round(dynOf(a).groundContactTime), name: a.name, ...paceMeta(a) }));
            const balData   = runsWithDynamics.filter(a => dynOf(a).groundContactBalance).map(a => ({ date: chartDate(a), value: parseFloat(dynOf(a).groundContactBalance.toFixed(1)), right: parseFloat((100-dynOf(a).groundContactBalance).toFixed(1)), name: a.name, ...paceMeta(a) }));
            const voData    = runsWithDynamics.filter(a => dynOf(a).verticalOscillation).map(a => ({ date: chartDate(a), value: parseFloat(normVO(dynOf(a).verticalOscillation).toFixed(1)), name: a.name, ...paceMeta(a) }));
            const vrData    = runsWithDynamics.filter(a => dynOf(a).verticalRatio).map(a => ({ date: chartDate(a), value: parseFloat(normVR(dynOf(a).verticalRatio).toFixed(1)), name: a.name, ...paceMeta(a) }));
            // Cadence — prefer the dynamics field, but fall back to
            // Strava's per-activity average_cadence so the chart still
            // renders for runs synced before Garmin dynamics were
            // captured. cadenceSpmOf normalizes both to steps-per-minute
            // (Strava's RPM gets doubled).
            // Cadence is the one metric Strava reports for nearly every
            // run (average_cadence), so — unlike GCT / vert-osc / power —
            // it must NOT be gated on the full Garmin running-dynamics
            // bundle (hasAnyField). Gating it there froze the chart at the
            // last dynamics-tagged run (e.g. the last bookmarklet import),
            // while newer Strava-only runs that DO carry cadence were
            // dropped. Build cadence from every form-quality run with a
            // cadence value so the chart tracks the latest runs.
            const cadRuns = allSorted
              .filter(a => isRun(a) && isFormQualityRun(a) && cadenceSpmOf(a) != null)
              .slice()
              .sort((a, b) => dateMs(a) - dateMs(b))
              .slice(-60);
            const cadData   = cadRuns.map(a => ({ date: chartDate(a), value: cadenceSpmOf(a), name: a.name, ...paceMeta(a) }));
            const powerData = runsWithDynamics.filter(a => dynOf(a).power).map(a => ({ date: chartDate(a), value: Math.round(dynOf(a).power), np: dynOf(a).normalizedPower ? Math.round(dynOf(a).normalizedPower) : null, name: a.name, ...paceMeta(a) }));
            const teData    = runsWithDynamics.filter(a => dynOf(a).trainingEffect).map(a => ({ date: chartDate(a), value: parseFloat((typeof dynOf(a).trainingEffect === "object" ? dynOf(a).trainingEffect.aerobic : dynOf(a).trainingEffect).toFixed(1)), name: a.name, ...paceMeta(a) }));
            // Stride length (m). Garmin stores cm internally; we already
            // converted to meters in the extraction. Plot in cm for
            // readability since stride changes show up at cm-scale.
            const stData    = runsWithDynamics.filter(a => dynOf(a).strideLengthSensor).map(a => ({ date: chartDate(a), value: Math.round(dynOf(a).strideLengthSensor * 100), name: a.name, ...paceMeta(a) }));
            // Stamina: begin / end / min as a 3-line chart. Big gap
            // between begin and end = late-race fade; min stamina
            // shows the worst point in the run.
            const stamData  = runsWithDynamics.filter(a => dynOf(a).beginningStamina != null || dynOf(a).endingStamina != null).map(a => ({
              date: chartDate(a),
              begin: dynOf(a).beginningStamina ?? null,
              end: dynOf(a).endingStamina ?? null,
              min: dynOf(a).minStamina ?? null,
              name: a.name,
              ...paceMeta(a),
            }));
            // Performance condition (Garmin's PC%, range typically -20..+20).
            // Negative = below baseline (off day or hard effort), positive
            // = above (PR-shape day).
            const pcData    = runsWithDynamics.filter(a => dynOf(a).performanceCondition != null).map(a => ({ date: chartDate(a), value: dynOf(a).performanceCondition, name: a.name, ...paceMeta(a) }));
            // Recovery time hours — Garmin's recommended gap before next
            // hard session. >36h consistently = under-recovered.
            const rcData    = runsWithDynamics.filter(a => dynOf(a).recoveryTime).map(a => ({ date: chartDate(a), value: dynOf(a).recoveryTime, name: a.name, ...paceMeta(a) }));
            // Step Speed Loss (cm/s) — Garmin's running-economy metric,
            // captured by extractors in PR #252 but never surfaced on
            // this tab. Lower = stride deceleration is smaller = more
            // efficient push-off. Was the "missing metric" the user
            // expected to see here.
            const sslData   = runsWithDynamics.filter(a => dynOf(a).stepSpeedLoss != null).map(a => ({ date: chartDate(a), value: parseFloat(dynOf(a).stepSpeedLoss.toFixed(1)), pct: dynOf(a).stepSpeedLossPct ?? null, name: a.name, ...paceMeta(a) }));
            // Anaerobic Training Effect — second axis of Garmin's TE.
            // Aerobic TE alone misses sprint / VO2 sessions where
            // anaerobic load dominates.
            const aTeData   = runsWithDynamics.filter(a => dynOf(a).anaerobicTrainingEffect != null).map(a => ({ date: chartDate(a), value: parseFloat(dynOf(a).anaerobicTrainingEffect.toFixed(1)), name: a.name, ...paceMeta(a) }));

            // Linear regression over the y-values, indexed by position
            // (oldest=0, newest=N-1). Returns the predicted y at each
            // index so a Recharts <Line dataKey="trendY"/> can render
            // it as a dashed overlay. Trend colour reflects whether the
            // direction matches "form improving" given the metric's
            // better-direction semantic — green when improving, red
            // when worsening, muted when noise dominates.
            const fitTrend = (data, yKey, betterDir) => {
              const pts = data.map((d, i) => ({ x: i, y: d[yKey] })).filter(p => Number.isFinite(p.y));
              if (pts.length < 4) return null;
              const n = pts.length;
              const mx = pts.reduce((s, p) => s + p.x, 0) / n;
              const my = pts.reduce((s, p) => s + p.y, 0) / n;
              let num = 0, dx2 = 0, dy2 = 0;
              pts.forEach(p => { const ddx = p.x - mx, ddy = p.y - my; num += ddx * ddy; dx2 += ddx * ddx; dy2 += ddy * ddy; });
              if (dx2 === 0 || dy2 === 0) return null;
              const slope = num / dx2;
              const intercept = my - slope * mx;
              const r = num / Math.sqrt(dx2 * dy2);
              const improving = betterDir === "lower" ? r < -0.15 : betterDir === "higher" ? r > 0.15 : false;
              const worsening = betterDir === "lower" ? r > 0.15 : betterDir === "higher" ? r < -0.15 : false;
              const color = improving ? C.green : worsening ? C.red : C.textMuted;
              return { slope, intercept, color, r };
            };

            const DynChart = ({ title, data, dataKey, unit, color, refLines, extra, domain, tick, betterDir, paceColored }) => {
              if (!data || data.length < 2) return null;
              const trend = fitTrend(data, dataKey || "value", betterDir);
              const dataAug = trend
                ? data.map((d, i) => ({ ...d, trendY: parseFloat((trend.slope * i + trend.intercept).toFixed(2)) }))
                : data;
              const ColoredDot = (props) => {
                const { cx, cy, payload } = props;
                if (cx == null || cy == null) return null;
                const fill = paceColored && payload?.paceZone ? ZONE_COLOR[payload.paceZone] : color;
                return React.createElement("circle", { cx, cy, r: 3, fill, stroke: fill });
              };
              return (
                <Card style={{ marginBottom: 16 }}>
                  <div style={{ fontSize: 12, fontWeight: 700, color: C.textDim, textTransform: "uppercase", letterSpacing: "0.05em", marginBottom: 12 }}>{title}</div>
                  <ResponsiveContainer width="100%" height={140}>
                    <LineChart data={dataAug} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
                      <CartesianGrid strokeDasharray="3 3" stroke={C.border} vertical={false} />
                      <XAxis dataKey="date" tick={{ fontSize: 9, fill: C.textMuted }} interval="preserveStartEnd" />
                      <YAxis tick={{ fontSize: 9, fill: C.textMuted }} domain={domain || ["auto","auto"]} tickFormatter={tick} />
                      <Tooltip contentStyle={{ background: C.bg2, border: `1px solid ${C.border}`, borderRadius: 6, fontSize: 11 }}
                        formatter={(v, n) => [v + unit, n]} labelStyle={{ color: C.textMuted }} />
                      {refLines && refLines.map(r => <ReferenceLine key={r.y} y={r.y} stroke={r.color || C.green} strokeDasharray="4 4"
                        label={{ value: r.label, fill: r.color || C.green, fontSize: 9, position: "right" }} />)}
                      <Line type="monotone" dataKey={dataKey} stroke={color} strokeWidth={2} dot={paceColored ? ColoredDot : { r: 2, fill: color }} activeDot={{ r: 4 }} name={unit ? dataKey : title} />
                      {trend && <Line type="linear" dataKey="trendY" stroke={trend.color} strokeWidth={1.5} strokeDasharray="6 3" dot={false} activeDot={false} legendType="none" name="trend" isAnimationActive={false} />}
                      {extra}
                    </LineChart>
                  </ResponsiveContainer>
                </Card>
              );
            };

            // Per-chart legend strip showing what each dot colour means.
            // Rendered once at the top so each chart doesn't need its own.
            const PaceLegend = () => (
              <div style={{ display: "flex", gap: 12, flexWrap: "wrap", fontSize: 10, color: C.textMuted, marginBottom: 12, alignItems: "center" }}>
                <span>Dot colour = pace zone:</span>
                {[
                  { z: "easy",   label: "Easy >9:00" },
                  { z: "steady", label: "Steady 8-9:00" },
                  { z: "tempo",  label: "Tempo 7-8:00" },
                  { z: "race",   label: "Race <7:00" },
                ].map(x => (
                  <span key={x.z} style={{ display: "inline-flex", alignItems: "center", gap: 4 }}>
                    <span style={{ width: 8, height: 8, borderRadius: 4, background: ZONE_COLOR[x.z], display: "inline-block" }} />
                    <span>{x.label}</span>
                  </span>
                ))}
                <span style={{ marginLeft: 8 }}>· dashed line = fitted trend (green=improving, red=worsening)</span>
              </div>
            );

            // Summary stats: latest vs 30-day avg
            const latest = runsWithDynamics[runsWithDynamics.length - 1]?.dynamics || {};
            const avg30 = runsWithDynamics.slice(-8); // ~last 8 runs as rolling avg
            const avg = key => avg30.filter(a => a.dynamics[key] != null).length
              ? (avg30.filter(a => a.dynamics[key] != null).reduce((s, a) => s + a.dynamics[key], 0) / avg30.filter(a => a.dynamics[key] != null).length)
              : null;
            // Cadence is special — falls back to Strava's RPM aggregate
            // (doubled) via cadenceSpmOf, and draws from the broader
            // cadRuns set (not just dynamics runs) so latest/avg match the
            // chart and reflect the most recent runs.
            const latestCadSpm = cadRuns.length ? cadenceSpmOf(cadRuns[cadRuns.length - 1]) : null;
            const avgCadSpm = (() => {
              const vs = cadRuns.slice(-8).map(cadenceSpmOf).filter(v => v != null);
              return vs.length ? Math.round(vs.reduce((s, v) => s + v, 0) / vs.length) : null;
            })();

            const formSummary = [
              { label: "Ground Contact", latest: latest.groundContactTime ? Math.round(latest.groundContactTime) + "ms" : "--", trend: avg("groundContactTime") ? Math.round(avg("groundContactTime")) + "ms" : "--", better: "lower", color: latest.groundContactTime < 240 ? C.green : latest.groundContactTime < 290 ? C.amber : C.red },
              { label: "L/R Balance", latest: latest.groundContactBalance ? `${latest.groundContactBalance.toFixed(1)} / ${(100-latest.groundContactBalance).toFixed(1)}%` : "--", trend: avg("groundContactBalance") ? `${avg("groundContactBalance").toFixed(1)}% L` : "--", better: "50/50", color: latest.groundContactBalance ? (Math.abs(latest.groundContactBalance-50) < 1.5 ? C.green : C.amber) : C.textMuted },
              { label: "Vert. Oscillation", latest: latest.verticalOscillation ? latest.verticalOscillation.toFixed(1) + "cm" : "--", trend: avg("verticalOscillation") ? avg("verticalOscillation").toFixed(1) + "cm" : "--", better: "lower", color: latest.verticalOscillation < 8 ? C.green : latest.verticalOscillation < 10 ? C.amber : C.red },
              { label: "Cadence", latest: latestCadSpm ? latestCadSpm + " spm" : "--", trend: avgCadSpm ? avgCadSpm + " spm" : "--", better: "higher", color: latestCadSpm >= 170 ? C.green : latestCadSpm >= 160 ? C.amber : C.red },
              { label: "Power", latest: latest.power ? Math.round(latest.power) + "W" : "--", trend: avg("power") ? Math.round(avg("power")) + "W" : "--", better: "context", color: C.amber },
              { label: "Vert. Ratio", latest: latest.verticalRatio ? latest.verticalRatio.toFixed(1) + "%" : "--", trend: avg("verticalRatio") ? avg("verticalRatio").toFixed(1) + "%" : "--", better: "lower", color: latest.verticalRatio < 7 ? C.green : latest.verticalRatio < 9 ? C.amber : C.red },
              { label: "Stride", latest: latest.strideLengthSensor ? Math.round(latest.strideLengthSensor * 100) + "cm" : "--", trend: avg("strideLengthSensor") ? Math.round(avg("strideLengthSensor") * 100) + "cm" : "--", better: "context", color: C.cyan },
              { label: "Stamina End", latest: latest.endingStamina != null ? latest.endingStamina + "%" : "--", trend: avg("endingStamina") != null ? Math.round(avg("endingStamina")) + "%" : "--", better: "higher", color: latest.endingStamina >= 50 ? C.green : latest.endingStamina >= 25 ? C.amber : C.red },
              { label: "Perf. Condition", latest: latest.performanceCondition != null ? (latest.performanceCondition > 0 ? "+" : "") + latest.performanceCondition : "--", trend: avg("performanceCondition") != null ? (avg("performanceCondition") > 0 ? "+" : "") + Math.round(avg("performanceCondition")) : "--", better: "higher", color: latest.performanceCondition > 0 ? C.green : latest.performanceCondition >= -5 ? C.amber : C.red },
              { label: "Recovery Time", latest: latest.recoveryTime ? latest.recoveryTime + "h" : "--", trend: avg("recoveryTime") ? Math.round(avg("recoveryTime")) + "h" : "--", better: "lower", color: latest.recoveryTime <= 24 ? C.green : latest.recoveryTime <= 36 ? C.amber : C.red },
            ].filter(s => s.latest !== "--");

            return (
              <div>
                <div style={{ fontSize: 11, color: C.textMuted, marginBottom: 12 }}>
                  {runsWithDynamics.length} runs with Garmin dynamics · last run: {chartDate(runsWithDynamics[runsWithDynamics.length-1])}
                </div>

                {/* Summary strip */}
                {formSummary.length > 0 && (
                  <Card style={{ marginBottom: 16 }}>
                    <div style={{ fontSize: 11, fontWeight: 700, color: C.textDim, textTransform: "uppercase", letterSpacing: "0.05em", marginBottom: 10 }}>Latest · 8-Run Avg</div>
                    <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(130px, 1fr))", gap: 12 }}>
                      {formSummary.map(s => (
                        <div key={s.label}>
                          <div style={{ fontSize: 10, color: C.textMuted, marginBottom: 2 }}>{s.label}</div>
                          <div style={{ fontSize: 15, fontWeight: 700, color: s.color }}>{s.latest}</div>
                          <div style={{ fontSize: 10, color: C.textMuted }}>avg {s.trend}</div>
                        </div>
                      ))}
                    </div>
                  </Card>
                )}

                <PaceLegend />

                {DynChart({ title: "Ground Contact Time", data: gctData, dataKey: "value", unit: "ms", color: C.cyan,
                  refLines: [{ y: 220, label: "220ms", color: C.green }, { y: 260, label: "260ms", color: C.amber }],
                  betterDir: "lower", paceColored: true })}
                {DynChart({ title: "L/R Ground Contact Balance", data: balData, dataKey: "value", unit: "% L", color: C.purple,
                  domain: [46, 54], refLines: [{ y: 50, label: "50% (ideal)", color: C.green }],
                  paceColored: true,
                  extra: React.createElement(Line, { type: "monotone", dataKey: "right", stroke: C.pink, strokeWidth: 1.5, strokeDasharray: "4 4", dot: false, name: "% R" }) })}
                {DynChart({ title: "Vertical Oscillation", data: voData, dataKey: "value", unit: "cm", color: C.amber,
                  refLines: [{ y: 8, label: "8cm", color: C.green }], betterDir: "lower", paceColored: true })}
                {DynChart({ title: "Vertical Ratio", data: vrData, dataKey: "value", unit: "%", color: C.textDim,
                  betterDir: "lower", paceColored: true })}
                {DynChart({ title: "Cadence", data: cadData, dataKey: "value", unit: " spm", color: C.green,
                  refLines: [{ y: 170, label: "170 spm", color: C.green }, { y: 160, label: "160 spm", color: C.amber }],
                  domain: [150, 190], betterDir: "higher", paceColored: true })}
                {powerData.length >= 2 && (() => {
                  const trend = fitTrend(powerData, "value", "higher");
                  const dataAug = trend
                    ? powerData.map((d, i) => ({ ...d, trendY: parseFloat((trend.slope * i + trend.intercept).toFixed(2)) }))
                    : powerData;
                  const PowerDot = (props) => {
                    const { cx, cy, payload } = props;
                    if (cx == null || cy == null) return null;
                    const fill = payload?.paceZone ? ZONE_COLOR[payload.paceZone] : C.amber;
                    return React.createElement("circle", { cx, cy, r: 3, fill, stroke: fill });
                  };
                  return (
                    <Card style={{ marginBottom: 16 }}>
                      <div style={{ fontSize: 12, fontWeight: 700, color: C.textDim, textTransform: "uppercase", letterSpacing: "0.05em", marginBottom: 12 }}>Running Power</div>
                      <ResponsiveContainer width="100%" height={140}>
                        <LineChart data={dataAug} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
                          <CartesianGrid strokeDasharray="3 3" stroke={C.border} vertical={false} />
                          <XAxis dataKey="date" tick={{ fontSize: 9, fill: C.textMuted }} interval="preserveStartEnd" />
                          <YAxis tick={{ fontSize: 9, fill: C.textMuted }} domain={["auto","auto"]} />
                          <Tooltip contentStyle={{ background: C.bg2, border: `1px solid ${C.border}`, borderRadius: 6, fontSize: 11 }} />
                          <Line type="monotone" dataKey="value" stroke={C.amber} strokeWidth={2} dot={PowerDot} name="Avg Power (W)" />
                          <Line type="monotone" dataKey="np" stroke={C.red} strokeWidth={1.5} strokeDasharray="4 4" dot={false} name="Norm Power (W)" />
                          {trend && <Line type="linear" dataKey="trendY" stroke={trend.color} strokeWidth={1.5} strokeDasharray="6 3" dot={false} activeDot={false} legendType="none" name="trend" isAnimationActive={false} />}
                        </LineChart>
                      </ResponsiveContainer>
                    </Card>
                  );
                })()}
                {DynChart({ title: "Aerobic Training Effect", data: teData, dataKey: "value", unit: "/5", color: C.purple,
                  domain: [1, 5], refLines: [{ y: 3, label: "3.0 (improving)", color: C.green }],
                  betterDir: "higher", paceColored: true })}
                {DynChart({ title: "Anaerobic Training Effect", data: aTeData, dataKey: "value", unit: "/5", color: C.red,
                  domain: [0, 5], refLines: [{ y: 2, label: "2.0", color: C.amber }, { y: 3.5, label: "3.5", color: C.green }],
                  betterDir: "higher", paceColored: true })}

                {/* Step Speed Loss — Garmin's running-economy metric.
                    Lower = stride decelerates less between footstrikes
                    = more efficient push-off. The "missing metric"
                    captured in PR #252 but not previously surfaced
                    on the Form tab. */}
                {DynChart({ title: "Step Speed Loss", data: sslData, dataKey: "value", unit: "cm/s", color: C.cyan,
                  betterDir: "lower", paceColored: true })}

                {/* Stride length — efficiency marker, climbs as fitness builds. */}
                {DynChart({ title: "Stride Length", data: stData, dataKey: "value", unit: "cm", color: C.cyan,
                  betterDir: "higher", paceColored: true })}

                {/* Stamina — beginning vs ending vs minimum. The gap
                    between begin and end is the late-race fade signal. */}
                {stamData.length >= 2 && (
                  <Card style={{ marginBottom: 16 }}>
                    <div style={{ fontSize: 12, fontWeight: 700, color: C.textDim, textTransform: "uppercase", letterSpacing: "0.05em", marginBottom: 12 }}>Stamina (begin / end / min)</div>
                    <ResponsiveContainer width="100%" height={140}>
                      <LineChart data={stamData} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
                        <CartesianGrid strokeDasharray="3 3" stroke={C.border} vertical={false} />
                        <XAxis dataKey="date" tick={{ fontSize: 9, fill: C.textMuted }} interval="preserveStartEnd" />
                        <YAxis tick={{ fontSize: 9, fill: C.textMuted }} domain={[0, 100]} tickFormatter={v => v + "%"} />
                        <Tooltip contentStyle={{ background: C.bg2, border: `1px solid ${C.border}`, borderRadius: 6, fontSize: 11 }} formatter={(v, n) => [v + "%", n]} />
                        <Line type="monotone" dataKey="begin" stroke={C.green} strokeWidth={2} dot={{ r: 2, fill: C.green }} name="Begin" connectNulls />
                        <Line type="monotone" dataKey="end" stroke={C.amber} strokeWidth={2} dot={{ r: 2, fill: C.amber }} name="End" connectNulls />
                        <Line type="monotone" dataKey="min" stroke={C.red} strokeWidth={1.5} strokeDasharray="4 4" dot={false} name="Min" connectNulls />
                      </LineChart>
                    </ResponsiveContainer>
                  </Card>
                )}

                {/* Performance condition — Garmin's PC%, +/- around baseline.
                    Negative on hard sessions, positive on PR-shape days. */}
                {DynChart({ title: "Performance Condition", data: pcData, dataKey: "value", unit: "", color: C.amber,
                  refLines: [{ y: 0, label: "baseline", color: C.textMuted }],
                  betterDir: "higher", paceColored: true })}

                {/* Recovery time — hours Garmin recommends before next
                    quality session. Stuck >36h = under-recovered. */}
                {DynChart({ title: "Recovery Time", data: rcData, dataKey: "value", unit: "h", color: C.red,
                  refLines: [{ y: 24, label: "24h", color: C.green }, { y: 48, label: "48h", color: C.amber }],
                  betterDir: "lower", paceColored: true })}

                {/* ── Form vs Pace ────────────────────────────────────────
                    Athlete intuition: form improves with pace (shorter
                    GCT, higher cadence, lower vertical osc as you push).
                    This validates that against the data — bucket every
                    run by avg pace and surface the form metrics per
                    bucket. Below the table, scatter plots show the raw
                    correlation: each dot a run, x-axis pace, y-axis
                    metric. A green ↓ next to a metric label means
                    "lower is better" (so the trend that confirms the
                    hypothesis goes down-and-right). */}
                {(() => {
                  const M_PER_MI = 1609.34;
                  const paceOf = a => (a.distance > 0 && a.moving_time > 0)
                    ? a.moving_time / (a.distance / M_PER_MI) : null;
                  const enriched = runsWithDynamics
                    .map(a => ({ a, dyn: dynOf(a), paceSec: paceOf(a), dateMs: dateMs(a) }))
                    .filter(r => r.paceSec && r.paceSec > 240 && r.paceSec < 900);
                  if (enriched.length < 4) return null;
                  // Recency → opacity. Recent runs render solid, older ones
                  // fade, so the eye reads the form-vs-pace cloud as movement
                  // over time (newest dots lead the trend). Linear over the
                  // window's date span, floored at 0.25 so the oldest still
                  // shows. Every point below carries `dateMs` for this.
                  const _recDms = enriched.map(r => r.dateMs).filter(Number.isFinite);
                  const _recMin = _recDms.length ? Math.min(..._recDms) : 0;
                  const _recMax = _recDms.length ? Math.max(..._recDms) : 1;
                  const recencyOpacity = (dms, base) => {
                    const b = base == null ? 1 : base;
                    if (!Number.isFinite(dms) || _recMax <= _recMin) return b;
                    const f = (dms - _recMin) / (_recMax - _recMin); // 0 oldest → 1 newest
                    return Math.round(b * (0.25 + 0.75 * f) * 1000) / 1000;
                  };
                  // Pace zones (sec/mi):
                  //   easy    >540  (>9:00)
                  //   steady  480–540  (8:00–9:00)
                  //   tempo   420–480  (7:00–8:00)
                  //   race    <420  (<7:00)
                  const zoneOf = sec => sec > 540 ? "easy" : sec > 480 ? "steady" : sec > 420 ? "tempo" : "race";
                  const zoneColor = z => z === "race" ? C.red : z === "tempo" ? C.amber : z === "steady" ? C.cyan : C.green;
                  const zoneLabel = z => z === "race" ? "Race (<7:00)" : z === "tempo" ? "Tempo (7–8:00)" : z === "steady" ? "Steady (8–9:00)" : "Easy (>9:00)";
                  const fmtPaceSec = sec => `${Math.floor(sec / 60)}:${String(Math.round(sec % 60)).padStart(2, "0")}`;
                  // Group by zone.
                  const zones = ["easy", "steady", "tempo", "race"];
                  const grouped = {};
                  zones.forEach(z => grouped[z] = []);
                  enriched.forEach(r => grouped[zoneOf(r.paceSec)].push(r));
                  // Lap-level points for the cadence & HR scatters —
                  // explodes the stored splits from quality sessions
                  // (qualityLaps, fetched lazily when the tab opens) so
                  // strides/intervals show up in the fast zones. Whole-run
                  // averages of those sessions land in easy/steady because
                  // of warmups & recovery jogs. Strava reports lap cadence
                  // in RPM (one foot) just like the activity aggregate —
                  // double it (the <120 guard prevents double-counting).
                  const lapPointsFor = (valFn) => Object.entries(qualityLaps).flatMap(([id, laps]) => {
                    if (!Array.isArray(laps) || laps.length < 2) return [];
                    const run = runsWithDynamics.find(a => String(a.id) === id) || allSorted.find(a => String(a.id) === id);
                    const runName = run?.name || "lap";
                    const runDate = run ? dateMs(run) : null;
                    return laps.map(l => {
                      // ≥60m keeps short strides (~15-20s) but drops accidental
                      // tiny lap presses.
                      if (!(l.distance > 60) || !(l.moving_time > 0)) return null;
                      const v = valFn(l);
                      if (v == null || !Number.isFinite(v)) return null;
                      const ps = l.moving_time / (l.distance / M_PER_MI);
                      if (!(ps > 130 && ps < 900)) return null; // ~2:10–15:00 /mi
                      return { paceSec: ps, value: v, zone: zoneOf(ps), name: runName, isLap: true, dateMs: runDate };
                    }).filter(Boolean);
                  });
                  const cadLapPts = lapPointsFor(l => { const c = l.average_cadence; return c > 0 ? Math.round(c < 120 ? c * 2 : c) : null; });
                  const hrLapPts  = lapPointsFor(l => l.average_heartrate > 0 ? Math.round(l.average_heartrate) : null);
                  // 0.25-mile blocks from the per-second streams. Whole-run
                  // averages collapse a progression / tempo / interval run
                  // into one easy-or-steady dot; blocking every quarter mile
                  // surfaces the fast portions in the tempo & race zones.
                  // HR & cadence come from the Strava streams; GCT / vert
                  // osc / vert ratio / stride / power from the Garmin
                  // per-second running-dynamics stream (detail/dynamics).
                  const BLOCK_M = 0.25 * M_PER_MI;
                  // Walk the samples, closing a block each time cumulative
                  // distance crosses 0.25mi. Each block carries forward its
                  // boundary sample so coverage stays continuous.
                  const blockGroups = (streams) => {
                    const groups = [];
                    let cur = [];
                    let startD = null;
                    for (const s of streams) {
                      if (s == null || s.d == null || s.t == null) continue;
                      if (startD == null) startD = s.d;
                      cur.push(s);
                      if (s.d - startD >= BLOCK_M) { groups.push(cur); cur = [s]; startD = s.d; }
                    }
                    if (cur.length >= 2 && (cur[cur.length - 1].d - cur[0].d) >= 0.6 * BLOCK_M) groups.push(cur);
                    return groups;
                  };
                  const blockPace = (pts) => {
                    const first = pts[0], last = pts[pts.length - 1];
                    const distM = last.d - first.d, timeS = last.t - first.t;
                    if (!(distM > 0) || !(timeS > 0)) return null;
                    const paceSec = timeS / (distM / M_PER_MI);
                    return (paceSec > 130 && paceSec < 1200) ? paceSec : null; // ~2:10–20:00 /mi
                  };
                  const runFor = (id) => runsWithDynamics.find(a => String(a.id) === id) || allSorted.find(a => String(a.id) === id);
                  const runNameFor = (id) => runFor(id)?.name || "run";
                  const runDateFor = (id) => { const run = runFor(id); return run ? dateMs(run) : null; };
                  const streamBlocks = Object.entries(qualityStreams).flatMap(([id, streams]) => {
                    if (!Array.isArray(streams) || streams.length < 4) return [];
                    const runName = runNameFor(id);
                    const runDate = runDateFor(id);
                    return blockGroups(streams).map(pts => {
                      const paceSec = blockPace(pts);
                      if (paceSec == null) return null;
                      const hrPts = pts.filter(p => p.hr > 0);
                      const cadPts = pts.filter(p => p.cad > 0);
                      const hr = hrPts.length ? Math.round(hrPts.reduce((a, p) => a + p.hr, 0) / hrPts.length) : null;
                      let cad = null;
                      if (cadPts.length) {
                        const raw = cadPts.reduce((a, p) => a + p.cad, 0) / cadPts.length;
                        cad = Math.round(raw < 120 ? raw * 2 : raw); // Strava reports per-leg cadence
                      }
                      return { paceSec, hr, cad, zone: zoneOf(paceSec), name: runName, dateMs: runDate };
                    }).filter(Boolean);
                  });
                  const hrBlockPts  = streamBlocks.filter(b => b.hr  != null).map(b => ({ paceSec: b.paceSec, value: b.hr,  zone: b.zone, name: b.name, isBlock: true, dateMs: b.dateMs }));
                  const cadBlockPts = streamBlocks.filter(b => b.cad != null).map(b => ({ paceSec: b.paceSec, value: b.cad, zone: b.zone, name: b.name, isBlock: true, dateMs: b.dateMs }));
                  // Garmin per-second running-dynamics blocks (GCT/VO/VR/stride/power).
                  const dynStreamBlocks = Object.entries(qualityDynamics).flatMap(([id, streams]) => {
                    if (!Array.isArray(streams) || streams.length < 4) return [];
                    const runName = runNameFor(id);
                    const runDate = runDateFor(id);
                    return blockGroups(streams).map(pts => {
                      const paceSec = blockPace(pts);
                      if (paceSec == null) return null;
                      const avg = (key) => {
                        const vs = pts.map(p => p[key]).filter(v => v != null);
                        return vs.length ? vs.reduce((a, b) => a + b, 0) / vs.length : null;
                      };
                      let sl = avg("sl");
                      if (sl != null && sl < 10) sl *= 100; // metres → cm guard
                      let ssl = avg("ssl");
                      if (ssl != null && ssl < 5) ssl *= 100; // m/s → cm/s guard (pre-fix streams)
                      const bal = avg("bal"); // GCT balance — left-foot %
                      return { paceSec, zone: zoneOf(paceSec), name: runName, dateMs: runDate,
                        gct: avg("gct"), vo: avg("vo"), vr: avg("vr"), sl, pwr: avg("pwr"),
                        ssl, resp: avg("resp"), pc: avg("pc"),
                        asym: bal != null ? Math.abs(bal - 50) : null };
                    }).filter(Boolean);
                  });
                  const dynBlockPts = (key, fmt) => dynStreamBlocks.filter(b => b[key] != null).map(b => ({ paceSec: b.paceSec, value: fmt(b[key]), zone: b.zone, name: b.name, isBlock: true, dateMs: b.dateMs }));
                  const gctBlockPts  = dynBlockPts("gct",  v => Math.round(v));
                  const voBlockPts   = dynBlockPts("vo",   v => parseFloat(v.toFixed(1)));
                  const vrBlockPts   = dynBlockPts("vr",   v => parseFloat(v.toFixed(1)));
                  const slBlockPts   = dynBlockPts("sl",   v => Math.round(v));
                  const pwrBlockPts  = dynBlockPts("pwr",  v => Math.round(v));
                  const sslBlockPts  = dynBlockPts("ssl",  v => parseFloat(v.toFixed(1)));
                  const asymBlockPts = dynBlockPts("asym", v => parseFloat(v.toFixed(2)));
                  const respBlockPts = dynBlockPts("resp", v => parseFloat(v.toFixed(1)));
                  const pcBlockPts   = dynBlockPts("pc",   v => Math.round(v));
                  // The summary table mirrors the charts: when a metric has
                  // 0.25-mile block coverage it is bucketed by block — so the
                  // fast quarters of a progression run land in Tempo/Race —
                  // otherwise it falls back to whole-run averages.
                  const WALK_PACE_SEC = 11 * 60;
                  const blockPtsByKey = {
                    groundContactTime: gctBlockPts, cadenceSpm: cadBlockPts,
                    strideLengthSensor: slBlockPts, verticalOscillation: voBlockPts,
                    verticalRatio: vrBlockPts, averageHR: hrBlockPts,
                    respirationRate: respBlockPts, power: pwrBlockPts,
                    stepSpeedLoss: sslBlockPts, asymmetry: asymBlockPts,
                    performanceCondition: pcBlockPts,
                  };
                  // Canonical block set for per-zone sample counts — every
                  // block carries a pace; Strava stream blocks cover the most
                  // runs so prefer them, walks excluded.
                  const paceBlocks = (streamBlocks.length ? streamBlocks : dynStreamBlocks)
                    .filter(b => b.paceSec <= WALK_PACE_SEC);
                  const useBlockN = paceBlocks.length >= 4;
                  const zoneN = (z) => useBlockN
                    ? paceBlocks.filter(b => zoneOf(b.paceSec) === z).length
                    : grouped[z].length;
                  // Each field supplies a read(r) that returns its value
                  // for an enriched run (or null). This lets us mix
                  // dynamics fields (r.dyn.*) with activity-level fields
                  // (r.a.average_heartrate) and computed fields (L/R
                  // asymmetry = |balance - 50|) under one framework.
                  // better:"context" = the metric naturally tracks pace
                  // (HR, power) — direction is shown but not moralized
                  // green/red, because "HR rises with pace" isn't a fault.
                  const fields = [
                    { key: "groundContactTime",   label: "GCT",         unit: "ms",  fmt: v => Math.round(v),     better: "lower",   read: r => r.dyn.groundContactTime },
                    { key: "cadenceSpm",          label: "Cadence",     unit: "spm", fmt: v => Math.round(v),     better: "higher",  read: r => cadenceSpmOf(r.a) },
                    { key: "strideLengthSensor",  label: "Stride",      unit: "cm",  fmt: v => Math.round(v),     better: "higher",  read: r => r.dyn.strideLengthSensor != null ? r.dyn.strideLengthSensor * 100 : null },
                    { key: "verticalOscillation", label: "VO",          unit: "cm",  fmt: v => v.toFixed(1),       better: "lower",   read: r => r.dyn.verticalOscillation },
                    { key: "verticalRatio",       label: "VR",          unit: "%",   fmt: v => v.toFixed(1),       better: "lower",   read: r => r.dyn.verticalRatio },
                    { key: "averageHR",           label: "Avg HR",      unit: "bpm", fmt: v => Math.round(v),     better: "context", read: r => r.a.average_heartrate || null },
                    { key: "respirationRate",     label: "Respiration", unit: "brpm", fmt: v => Math.round(v),    better: "context", read: r => r.dyn.respirationRate },
                    { key: "power",               label: "Power",       unit: "W",   fmt: v => Math.round(v),     better: "context", read: r => r.dyn.power },
                    { key: "stepSpeedLoss",       label: "Step Speed Loss", unit: "%", fmt: v => v.toFixed(1),    better: "lower",   read: r => r.dyn.stepSpeedLoss },
                    { key: "asymmetry",           label: "L/R Asymmetry", unit: "%", fmt: v => v.toFixed(1),       better: "lower",   read: r => r.dyn.groundContactBalance != null ? Math.abs(r.dyn.groundContactBalance - 50) : null },
                    { key: "performanceCondition", label: "Perf Cond",  unit: "",    fmt: v => Math.round(v),     better: "higher",  read: r => r.dyn.performanceCondition },
                  ];
                  // Per-field data points: 0.25-mile blocks when the metric
                  // has ≥4 of them (the threshold the charts use), else
                  // whole-run averages. Memoised — read by the table cells,
                  // the trend correlation and the usable-field filter.
                  const _ptCache = {};
                  const pointsFor = (field) => {
                    if (_ptCache[field.key]) return _ptCache[field.key];
                    const bp = (blockPtsByKey[field.key] || []).filter(p => p.paceSec <= WALK_PACE_SEC);
                    let pts;
                    if (bp.length >= 4) {
                      pts = bp.map(p => ({ paceSec: p.paceSec, val: p.value }));
                    } else {
                      pts = enriched
                        .map(r => ({ paceSec: r.paceSec, val: field.read(r) }))
                        .filter(p => p.val != null && p.paceSec <= WALK_PACE_SEC);
                    }
                    return (_ptCache[field.key] = pts);
                  };
                  const avgInZone = (field, zone) => {
                    const xs = pointsFor(field).filter(p => zoneOf(p.paceSec) === zone).map(p => p.val);
                    if (!xs.length) return null;
                    return xs.reduce((s, v) => s + v, 0) / xs.length;
                  };
                  // Identify which fields actually have ≥2 zone buckets w/ data — others render N/A everywhere and add no signal.
                  const usableFields = fields.filter(f => zones.filter(z => avgInZone(f, z) != null).length >= 2);
                  if (!usableFields.length) return null;
                  // Pearson correlation between paceSec and metric — used to
                  // describe each scatter as "↓ with pace" / "↑ with pace" / "flat".
                  const corr = (field) => {
                    const xs = pointsFor(field);
                    if (xs.length < 4) return null;
                    const n = xs.length;
                    const mx = xs.reduce((s, p) => s + p.paceSec, 0) / n;
                    const my = xs.reduce((s, p) => s + p.val, 0) / n;
                    let num = 0, dx2 = 0, dy2 = 0;
                    xs.forEach(p => {
                      const ddx = p.paceSec - mx;
                      const ddy = p.val - my;
                      num += ddx * ddy; dx2 += ddx * ddx; dy2 += ddy * ddy;
                    });
                    if (dx2 === 0 || dy2 === 0) return null;
                    return num / Math.sqrt(dx2 * dy2);
                  };
                  const trendLabel = (f) => {
                    const r = corr(f);
                    if (r == null) return null;
                    // r > 0 means metric goes UP as pace seconds go UP (= slower).
                    // Equivalently, metric goes DOWN as pace gets FASTER.
                    //
                    // Arrow points the direction the METRIC is moving as pace
                    // gets faster (so it always tells you what's actually
                    // happening to the number). r > 0 → metric increases as
                    // pace_seconds increase → metric DECREASES as pace gets
                    // faster (so arrow ↓).
                    const flat = Math.abs(r) < 0.2;
                    const arrow = flat ? "→" : (r > 0 ? "↓" : "↑");
                    const dirTxt = flat ? "→ flat" : `${arrow} faster`;
                    // context = naturally tracks pace (HR/power): show the
                    // direction, no green/red verdict.
                    if (f.better === "context") return { txt: dirTxt, col: C.textDim };
                    // Otherwise colour by whether that direction matches the
                    // metric's "better" semantic — green improving, red worsening.
                    const improvesWithPace = (f.better === "lower" && r > 0.2) || (f.better === "higher" && r < -0.2);
                    const worsensWithPace  = (f.better === "lower" && r < -0.2) || (f.better === "higher" && r > 0.2);
                    if (improvesWithPace) return { txt: dirTxt, col: C.green };
                    if (worsensWithPace)  return { txt: dirTxt, col: C.red };
                    return { txt: "→ flat", col: C.textMuted };
                  };

                  return (
                    <Card style={{ marginBottom: 16 }}>
                      <div style={{ fontSize: 12, fontWeight: 700, color: C.textDim, textTransform: "uppercase", letterSpacing: "0.05em", marginBottom: 4 }}>Form vs Pace</div>
                      <div style={{ fontSize: 10, color: C.textMuted, marginBottom: 12, lineHeight: 1.5 }}>
                        Tests the "my form improves with pace" hypothesis. Each row shows the average metric per pace zone — bucketed per 0.25-mile block where per-second data exists, so the fast quarters of easy runs still fill the Tempo & Race columns. The trend column reports the Pearson correlation.
                      </div>
                      {/* Bucket table */}
                      <div style={{ overflowX: "auto" }}>
                        <table style={{ width: "100%", borderCollapse: "collapse", fontSize: 11, fontFamily: "'IBM Plex Mono',monospace" }}>
                          <thead>
                            <tr>
                              <th style={{ textAlign: "left", padding: "6px 8px", color: C.textMuted, fontWeight: 600, borderBottom: `1px solid ${C.border}` }}>Metric</th>
                              {zones.map(z => (
                                <th key={z} style={{ textAlign: "right", padding: "6px 8px", color: zoneColor(z), fontWeight: 700, borderBottom: `1px solid ${C.border}` }}>
                                  {zoneLabel(z)}
                                  <div style={{ fontSize: 9, color: C.textMuted, fontWeight: 400 }}>n={zoneN(z)}</div>
                                </th>
                              ))}
                              <th style={{ textAlign: "right", padding: "6px 8px", color: C.textMuted, fontWeight: 600, borderBottom: `1px solid ${C.border}` }}>Trend</th>
                            </tr>
                          </thead>
                          <tbody>
                            {usableFields.map(f => {
                              const tr = trendLabel(f);
                              return (
                                <tr key={f.key}>
                                  <td style={{ padding: "6px 8px", color: C.text, fontWeight: 600 }}>
                                    {f.label} <span style={{ fontSize: 9, color: C.textMuted, fontWeight: 400 }}>({f.unit})</span>
                                  </td>
                                  {zones.map(z => {
                                    const v = avgInZone(f, z);
                                    return (
                                      <td key={z} style={{ textAlign: "right", padding: "6px 8px", color: v != null ? C.text : C.textMuted }}>
                                        {v != null ? f.fmt(v) : "—"}
                                      </td>
                                    );
                                  })}
                                  <td style={{ textAlign: "right", padding: "6px 8px", color: tr ? tr.col : C.textMuted, fontWeight: 600 }}>
                                    {tr ? tr.txt : "—"}
                                  </td>
                                </tr>
                              );
                            })}
                          </tbody>
                        </table>
                      </div>

                      {/* Scatter for the headline metric (GCT). x = pace,
                          y = GCT. One dot per run, coloured by pace zone.
                          Reveals the correlation visually beyond the
                          table averages. */}
                      {/* One scatter per metric — pace on X (reversed
                          so faster = right), metric on Y. Renders only
                          when the metric has 4+ data points. Y-axis
                          formatter adapted per metric. */}
                      {(() => {
                        const scatterFor = (read, valueFn) => enriched
                          .filter(r => read(r) != null && Number.isFinite(valueFn(r)))
                          .map(r => ({ paceSec: r.paceSec, value: valueFn(r), zone: zoneOf(r.paceSec), name: r.a.name, dateMs: r.dateMs }));
                        // Linear regression: returns two endpoints (min and max
                        // paceSec projected onto the fit line) so a Scatter with
                        // line={...} can render the trend without us doing
                        // anything fancier than a 2-point dataset.
                        const trendLine = (points, betterDir) => {
                          if (!points || points.length < 4) return null;
                          const n = points.length;
                          const mx = points.reduce((s, p) => s + p.paceSec, 0) / n;
                          const my = points.reduce((s, p) => s + p.value, 0) / n;
                          let num = 0, den = 0;
                          points.forEach(p => {
                            num += (p.paceSec - mx) * (p.value - my);
                            den += (p.paceSec - mx) ** 2;
                          });
                          if (den === 0) return null;
                          const slope = num / den;
                          const intercept = my - slope * mx;
                          // Pearson correlation strength → colour. Same logic
                          // as the table trend column: green when the metric's
                          // direction at faster paces means form improves; red
                          // when it doesn't; muted for noise.
                          let dy2 = 0;
                          points.forEach(p => { dy2 += (p.value - my) ** 2; });
                          const r = (den === 0 || dy2 === 0) ? 0 : num / Math.sqrt(den * dy2);
                          const improving = (betterDir === "lower" && r > 0.2) || (betterDir === "higher" && r < -0.2);
                          const worsening = (betterDir === "lower" && r < -0.2) || (betterDir === "higher" && r > 0.2);
                          const color = betterDir === "context" ? C.textDim : improving ? C.green : worsening ? C.red : C.textMuted;
                          const xs = points.map(p => p.paceSec);
                          const xMin = Math.min(...xs);
                          const xMax = Math.max(...xs);
                          return {
                            data: [
                              { paceSec: xMin, value: intercept + slope * xMin },
                              { paceSec: xMax, value: intercept + slope * xMax },
                            ],
                            color,
                          };
                        };
                        const charts = [
                          { key: "groundContactTime",   label: "GCT vs Pace",         tickFmt: v => v + "ms",   unit: "ms",  better: "lower",   read: r => r.dyn.groundContactTime,                                                          fn: r => Math.round(r.dyn.groundContactTime), blockPoints: gctBlockPts },
                          { key: "cadenceSpm",          label: "Cadence vs Pace",     tickFmt: v => v + " spm", unit: " spm", better: "higher",  read: r => cadenceSpmOf(r.a),                                                                 fn: r => cadenceSpmOf(r.a), lapPoints: cadLapPts, blockPoints: cadBlockPts },
                          { key: "strideLengthSensor",  label: "Stride vs Pace",      tickFmt: v => v + "cm",   unit: "cm",  better: "higher",  read: r => r.dyn.strideLengthSensor,                                                         fn: r => Math.round(r.dyn.strideLengthSensor * 100), blockPoints: slBlockPts },
                          { key: "verticalOscillation", label: "Vert Osc vs Pace",    tickFmt: v => v + "cm",   unit: "cm",  better: "lower",   read: r => r.dyn.verticalOscillation,                                                        fn: r => parseFloat(r.dyn.verticalOscillation.toFixed(1)), blockPoints: voBlockPts },
                          { key: "verticalRatio",       label: "Vert Ratio vs Pace",  tickFmt: v => v + "%",    unit: "%",   better: "lower",   read: r => r.dyn.verticalRatio,                                                              fn: r => parseFloat(r.dyn.verticalRatio.toFixed(1)), blockPoints: vrBlockPts },
                          { key: "averageHR",           label: "HR vs Pace",          tickFmt: v => v + "bpm",  unit: "bpm", better: "context", read: r => r.a.average_heartrate || null,                                                    fn: r => Math.round(r.a.average_heartrate), lapPoints: hrLapPts, blockPoints: hrBlockPts },
                          { key: "respirationRate",     label: "Respiration vs Pace", tickFmt: v => v + " brpm", unit: " brpm", better: "context", read: r => r.dyn.respirationRate,                                                          fn: r => parseFloat(r.dyn.respirationRate.toFixed(1)), blockPoints: respBlockPts },
                          { key: "power",               label: "Power vs Pace",       tickFmt: v => v + "W",    unit: "W",   better: "context", read: r => r.dyn.power,                                                                      fn: r => Math.round(r.dyn.power), blockPoints: pwrBlockPts },
                          { key: "stepSpeedLoss",       label: "Step Speed Loss vs Pace", tickFmt: v => v + " cm/s", unit: " cm/s", better: "lower", read: r => r.dyn.stepSpeedLoss,                                                          fn: r => parseFloat(r.dyn.stepSpeedLoss.toFixed(1)), blockPoints: sslBlockPts },
                          { key: "asymmetry",           label: "L/R Asymmetry vs Pace", tickFmt: v => v + "%",  unit: "%",   better: "lower",   read: r => r.dyn.groundContactBalance != null ? Math.abs(r.dyn.groundContactBalance - 50) : null, fn: r => parseFloat(Math.abs(r.dyn.groundContactBalance - 50).toFixed(2)), blockPoints: asymBlockPts },
                          { key: "performanceCondition", label: "Perf Cond vs Pace",  tickFmt: v => v,          unit: "",    better: "higher",  read: r => r.dyn.performanceCondition,                                                       fn: r => Math.round(r.dyn.performanceCondition), blockPoints: pcBlockPts },
                        ];
                        // Walk segments (slower than 11:00 /mi) aren't running
                        // form — drop them so they don't drag the regression
                        // line or stretch the pace axis out to walking pace.
                        // WALK_PACE_SEC is defined once above (table scope).
                        const noWalks = pts => pts.filter(p => p.paceSec <= WALK_PACE_SEC);
                        // Prefer 0.25mi block points when streams are loaded
                        // (HR & Cadence); otherwise fall back to whole-run
                        // averages + lap dots so the chart still renders
                        // before streams arrive / for runs without streams.
                        const renderable = charts
                          .map(c => {
                            const blocks = noWalks(c.blockPoints || []);
                            if (blocks.length >= 4) return { ...c, data: blocks, lp: [], blockBased: true };
                            return { ...c, data: noWalks(scatterFor(c.read, c.fn)), lp: noWalks(c.lapPoints || []), blockBased: false };
                          })
                          .map(c => ({ ...c, allPts: [...c.data, ...c.lp] }))
                          .map(c => ({ ...c, trend: trendLine(c.allPts, c.better) }))
                          .filter(c => c.allPts.length >= 4);
                        if (!renderable.length) return null;
                        const anyBlocks = renderable.some(c => c.blockBased);
                        return (
                          <div style={{ marginTop: 16, display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(360px, 1fr))", gap: 16 }}>
                            {renderable.map(c => (
                              <div key={c.key}>
                                <div style={{ fontSize: 10, fontWeight: 700, color: C.textDim, textTransform: "uppercase", letterSpacing: "0.05em", marginBottom: 8 }}>{c.label}</div>
                                <ResponsiveContainer width="100%" height={160}>
                                  <ScatterChart margin={{ top: 4, right: 8, left: -10, bottom: 0 }}>
                                    <CartesianGrid strokeDasharray="3 3" stroke={C.border} />
                                    <XAxis type="number" dataKey="paceSec" name="Pace" domain={["dataMin - 10", "dataMax + 10"]}
                                      tick={{ fontSize: 9, fill: C.textMuted }} tickFormatter={fmtPaceSec} reversed />
                                    <YAxis type="number" dataKey="value" tick={{ fontSize: 9, fill: C.textMuted }} tickFormatter={c.tickFmt} domain={["auto", "auto"]} />
                                    <Tooltip cursor={{ strokeDasharray: "3 3" }}
                                      contentStyle={{ background: C.bg2, border: `1px solid ${C.border}`, borderRadius: 6, fontSize: 11 }}
                                      itemStyle={{ color: C.text }} labelStyle={{ color: C.textMuted }}
                                      formatter={(v, n) => n === "Pace" ? [fmtPaceSec(v), "Pace"] : [v + c.unit, c.label.replace(" vs Pace", "")]} />
                                    {/* Faint lap-level dots (cadence & HR only) —
                                        rendered first so the solid run-average
                                        dots sit on top. Coloured by the lap's
                                        pace zone. */}
                                    {c.lp.length > 0 && (
                                      <Scatter name="Laps" data={c.lp} isAnimationActive={false}
                                        shape={(p) => {
                                          if (p == null || p.cx == null || p.cy == null) return null;
                                          return React.createElement("circle", { cx: p.cx, cy: p.cy, r: 2.5, fill: zoneColor(p.payload?.zone), fillOpacity: recencyOpacity(p.payload?.dateMs, 0.32), stroke: "none" });
                                        }} />
                                    )}
                                    {zones.map(z => {
                                      const pts = c.data.filter(d => d.zone === z);
                                      if (!pts.length) return null;
                                      // Block-based charts carry many more
                                      // points — draw them smaller and a touch
                                      // translucent so density reads cleanly.
                                      return <Scatter key={z} name={zoneLabel(z)} data={pts} fill={zoneColor(z)} isAnimationActive={false}
                                        shape={(p) => {
                                          if (p == null || p.cx == null || p.cy == null) return null;
                                          // Recent runs solid, older fade — reads as progress over time.
                                          return React.createElement("circle", { cx: p.cx, cy: p.cy, r: c.blockBased ? 3 : 4, fill: zoneColor(z), fillOpacity: recencyOpacity(p.payload?.dateMs), stroke: "none" });
                                        }} />;
                                    })}
                                    {/* Linear regression trend line — invisible
                                        dots, dashed line drawn between two
                                        endpoints projected onto the fit. */}
                                    {c.trend && (
                                      <Scatter name="Trend" data={c.trend.data} fill="transparent"
                                        line={{ stroke: c.trend.color, strokeWidth: 2, strokeDasharray: "5 4" }}
                                        shape={() => null} />
                                    )}
                                  </ScatterChart>
                                </ResponsiveContainer>
                              </div>
                            ))}
                            <div style={{ gridColumn: "1 / -1", textAlign: "center", fontSize: 9, color: C.textMuted, fontStyle: "italic" }}>
                              X-axis reversed: faster pace on the right. Dots coloured by pace zone; recent runs render solid and older ones fade, so the cloud reads as movement over time.
                              {anyBlocks
                                ? " Charts with per-second data are plotted per 0.25-mile block of each run, so the fast portions of progression/tempo/interval runs land in the tempo & race zones. Charts without per-second data plot one dot per run (its average)."
                                : " Each dot = one run (its average)."}
                              {" "}Dashed line = linear regression; green = form improves with pace, red = worsens, grey = neutral (HR/power naturally track pace).
                              {" "}Walk segments (slower than 11:00/mi) are excluded.
                            </div>
                          </div>
                        );
                      })()}
                    </Card>
                  );
                })()}
              </div>
            );
          })()}

          {/* Auto-merge banner — shown when duplicates detected and not yet dismissed */}
          {tab === "Log" && duplicates.size > 0 && !autoMergePrompted && (
            <div style={{ display:"flex", alignItems:"center", gap: 12, padding: "10px 16px", background: C.amber + "10", borderRadius: 8, border: `1px solid ${C.amber}30` }}>
              <span style={{ fontSize: 16 }}>⚠️</span>
              <span style={{ fontSize: 12, color: C.amber, flex: 1 }}>
                <strong>{duplicates.size}</strong> duplicate activit{duplicates.size !== 1 ? "ies" : "y"} detected — likely Peloton + Garmin pairs
              </span>
              <button onClick={runAutoMerge} disabled={autoMerging}
                style={{ padding:"6px 14px", borderRadius:6, border:`1px solid ${C.amber}50`, background:C.amber+"20", color:C.amber, fontSize:12, fontWeight:700, cursor:autoMerging?"wait":"pointer" }}>
                {autoMerging ? "Merging…" : "Auto-Merge"}
              </button>
              <button onClick={() => setAutoMergePrompted(true)}
                style={{ padding:"4px 8px", borderRadius:5, border:`1px solid ${C.border}`, background:"transparent", color:C.textMuted, fontSize:11, cursor:"pointer" }}>
                Dismiss
              </button>
              {mergeResult && <span style={{ fontSize:11, color: mergeResult.success ? C.green : C.red }}>{mergeResult.msg}</span>}
            </div>
          )}

          {tab === "Log" && (
            <Card style={{ padding: 0, overflow:"hidden" }}>
              {filteredActivities.length === 0 ? (
                <EmptyState icon="📋" title="No activities" sub="Connect Strava and sync your activities" />
              ) : (
                <div className="activity-grid">
                  <div style={{
                    display:"grid", gridTemplateColumns:"28px 1fr 80px 80px 80px 70px 70px 40px",
                    gap: 12, padding:"12px 20px", borderBottom:`1px solid ${C.border}`,
                    fontSize: 11, color: C.textMuted, textTransform:"uppercase", letterSpacing:"0.06em"
                  }}>
                    <span></span><span>Activity</span><span style={{textAlign:"right"}}>Distance</span>
                    <span style={{textAlign:"right"}}>Time</span><span style={{textAlign:"right"}}>Pace</span>
                    <span style={{textAlign:"right"}}>HR</span><span style={{textAlign:"right"}}>Elev</span>
                    <span></span>
                  </div>
                  {filteredActivities.map((act, i) => {
                    const disp = actDisplay(effectiveType(act));
                    const run = isRun(act);
                    const hasDist = act.distance > 100;
                    const isChecked = multiSelected.has(act.id);
                    return (
                      <div key={act.id} onClick={() => { const isNew = selected?.id !== act.id; setSelected(isNew ? act : null); if (isNew) { fetchLaps(act.id); if (isRun(act)) loadInsight(act.id); const d = act.start_date?.toDate ? act.start_date.toDate() : new Date(act.start_date); const dateKey = d.toISOString().split("T")[0]; if (!nutritionByDate[dateKey]) { db.collection("users").doc(userId).collection("mfpDiary").doc(dateKey).get().then(doc => { if (doc.exists) setNutritionByDate(prev => ({ ...prev, [dateKey]: doc.data().totals })); }); } } }} style={{
                        display:"grid", gridTemplateColumns:"28px 1fr 80px 80px 80px 70px 70px 40px",
                        gap: 12, padding:"14px 20px",
                        borderBottom: i < filteredActivities.length-1 ? `1px solid ${C.border}` : "none",
                        cursor:"pointer", background: isChecked ? C.cyan + "10" : selected?.id === act.id ? C.bg2 : (duplicates.has(act.id) && actFilter === "duplicates" ? C.amber + "08" : "transparent"),
                        transition:"background 0.15s",
                      }}
                      onMouseEnter={e => { if (!isChecked) e.currentTarget.style.background = C.bg2; }}
                      onMouseLeave={e => { e.currentTarget.style.background = isChecked ? C.cyan + "10" : selected?.id === act.id ? C.bg2 : "transparent"; }}
                      >
                        {/* Checkbox for multi-select */}
                        <div style={{ display:"flex", alignItems:"center", justifyContent:"center" }} onClick={e => toggleMultiSelect(act.id, e)}>
                          <div style={{
                            width: 16, height: 16, borderRadius: 4, border: `1.5px solid ${isChecked ? C.cyan : C.border}`,
                            background: isChecked ? C.cyan : "transparent", display:"flex", alignItems:"center", justifyContent:"center",
                            transition:"all 0.1s", flexShrink: 0,
                          }}>
                            {isChecked && <span style={{ color: "#000", fontSize: 10, fontWeight: 900, lineHeight: 1 }}>✓</span>}
                          </div>
                        </div>
                        <div>
                          <div style={{ display:"flex", alignItems:"center", gap: 8 }}>
                            {!run && <span style={{ fontSize: 14 }}>{disp.emoji}</span>}
                            <div style={{ fontSize: 13, fontWeight: 600, color: C.text }}>{act.name || disp.label}</div>
                            {duplicates.has(act.id) && <span style={{ fontSize: 8, padding: "1px 5px", borderRadius: 3, background: C.amber + "20", color: C.amber, fontWeight: 700 }}>DUP</span>}
                            {(act.type === "VirtualRun" || act.dynamics?.isIndoor) && <span style={{ fontSize: 8, padding: "1px 5px", borderRadius: 3, background: C.purple + "20", color: C.purple, fontWeight: 700 }}>TREAD</span>}
                          </div>
                          <div style={{ display:"flex", gap: 8, marginTop: 2, alignItems: "center" }}>
                            {!run && <span style={{ fontSize: 9, color: disp.color, background: disp.color + "18",
                              border: `1px solid ${disp.color}40`, borderRadius: 4, padding: "1px 5px" }}>{disp.label}</span>}
                            <span style={{ fontSize: 11, color: C.textMuted }}>{fmt.date(act.start_date)}</span>
                          </div>
                        </div>
                        <div style={{ textAlign:"right", fontSize: 13, fontWeight:700, color: run ? C.cyan : disp.color }}>
                          {hasDist ? `${fmt.distShort(act.distance)}` : "--"} <span style={{fontSize:10,color:C.textMuted}}>{hasDist ? "mi" : ""}</span>
                        </div>
                        <div style={{ textAlign:"right", fontSize: 13, color:C.text }}>{fmt.duration(act.moving_time)}</div>
                        <div style={{ textAlign:"right", fontSize: 13, color: run ? C.amber : C.textMuted }}>
                          {hasDist ? fmt.pace(act.moving_time/(act.distance/1609.34)).split(" ")[0] : "--"}
                        </div>
                        <div style={{ textAlign:"right", fontSize: 13, color:C.pink }}>{act.average_heartrate ? Math.round(act.average_heartrate) : "--"}</div>
                        <div style={{ textAlign:"right", fontSize: 13, color:C.textDim }}>{act.total_elevation_gain ? `${Math.round(act.total_elevation_gain*3.28)}ft` : "--"}</div>
                        <div style={{ textAlign:"right" }}>
                          {isOwner && <button onClick={(e) => { e.stopPropagation(); if(confirm("Delete this activity?")) deleteActivity(act.id); }}
                            disabled={deleting === act.id}
                            style={{ background:"none", border:"none", cursor:"pointer", color: C.red + "80", fontSize: 14, padding: 2, lineHeight: 1 }}
                            title="Delete activity"
                          >{deleting === act.id ? "..." : "x"}</button>}
                        </div>
                      </div>
                    );
                  })}
                </div>
              )}
            </Card>
          )}

          {/* Activity detail slide-out drawer */}
          {selected && (
            <div onClick={() => setSelected(null)} style={{
              position: "fixed", top: 0, left: 0, right: 0, bottom: 0, background: "rgba(0,0,0,0.45)", zIndex: 140,
            }} />
          )}
          {selected && (() => {
            const disp = actDisplay(effectiveType(selected));
            const selRun = isRun(selected);
            const hasDist = selected.distance > 100;
            return (
              <div className="activity-detail-panel" style={{
                position: "fixed", top: 56, right: 0, bottom: 0, width: 500,
                background: C.bg1, borderLeft: `1px solid ${C.border}`,
                zIndex: 150, overflowY: "auto", overflowX: "hidden",
                boxShadow: "-6px 0 32px rgba(0,0,0,0.4)",
              }}>
              <Card style={{ borderRadius: 0, border: "none", borderBottom: `1px solid ${C.border}` }}>
                <div style={{ display:"flex", justifyContent:"space-between", alignItems:"center", marginBottom: 16 }}>
                  <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
                    {!selRun && <span style={{ fontSize: 22 }}>{disp.emoji}</span>}
                    <div>
                      <div style={{ fontFamily:"'Space Grotesk', sans-serif", fontSize: 18, fontWeight: 700, color: C.text }}>{selected.name}</div>
                      <div style={{ fontSize: 11, color: C.textMuted, marginTop: 2 }}>
                        {fmt.date(selected.start_date)}{!selRun ? ` · ${disp.label}` : ""}
                      </div>
                    </div>
                  </div>
                  <button onClick={() => setSelected(null)} style={{
                    background: "none", border: `1px solid ${C.border}`, borderRadius: 6,
                    color: C.textMuted, fontSize: 18, cursor: "pointer", width: 32, height: 32,
                    display: "flex", alignItems: "center", justifyContent: "center", lineHeight: 1,
                  }}>×</button>
                </div>
                <div style={{ display:"grid", gridTemplateColumns:"repeat(auto-fit, minmax(120px, 1fr))", gap: 16 }}>
                  {hasDist && <Stat label="Distance" value={fmt.distShort(selected.distance)} unit="mi" color={selRun ? C.cyan : disp.color} />}
                  <Stat label="Moving Time" value={fmt.duration(selected.moving_time)} unit="" color={C.text} />
                  {hasDist && <Stat label="Pace" value={fmt.pace(selected.moving_time/(selected.distance/1609.34)).split(" ")[0]} unit="/mi" color={C.amber} />}
                  <Stat label="Avg HR" value={selected.average_heartrate ? Math.round(selected.average_heartrate) : "--"} unit="bpm" color={C.pink} />
                  <Stat label="Max HR" value={selected.max_heartrate ? Math.round(selected.max_heartrate) : "--"} unit="bpm" color={C.red} />
                  {selected.total_elevation_gain > 0 && <Stat label="Elevation" value={Math.round(selected.total_elevation_gain*3.28)} unit="ft" color={C.green} />}
                  {selected.calories > 0 && <Stat label="Calories Burned" value={Math.round(selected.calories)} unit="kcal" color={C.amber} />}
                  {selected.suffer_score > 0 && <Stat label="Suffer Score" value={selected.suffer_score} unit="" color={C.purple} />}
                  {selRun && selected.average_cadence > 0 && <Stat label="Cadence" value={Math.round(selected.average_cadence * 2)} unit="spm" color={C.cyan} />}
                  {selRun && selected.average_cadence > 0 && hasDist && (() => {
                    const speed = selected.distance / selected.moving_time;
                    const strideCm = Math.round(speed / (selected.average_cadence / 60) * 100);
                    return <Stat label="Stride Length" value={strideCm} unit="cm" color={C.green} />;
                  })()}
                </div>

                {/* Nutrition for this day — use nutritionHistory (already fixMacro'd) */}
                {(() => {
                  const d = selected.start_date?.toDate ? selected.start_date.toDate() : new Date(selected.start_date);
                  const dateKey = d.toISOString().split("T")[0];
                  const nut = nutritionHistory?.find(n => n.date === dateKey) || nutritionByDate[dateKey];
                  if (!nut || !nut.calories) return null;
                  const burned = selected.calories || 0;
                  const net = burned > 0 ? nut.calories - burned : null;
                  return (
                    <div style={{ marginTop: 12, padding: "12px 16px", background: `${C.green}08`, borderRadius: 10, border: `1px solid ${C.green}20` }}>
                      <div style={{ fontSize: 11, fontWeight: 700, color: C.green, textTransform: "uppercase", letterSpacing: 1, marginBottom: 10 }}>
                        Nutrition — {dateKey}
                      </div>
                      <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(100px, 1fr))", gap: 12 }}>
                        <Stat label="Calories In" value={Math.round(nut.calories)} unit="kcal" color={C.amber} />
                        {net !== null && <Stat label="Net Calories" value={Math.round(net)} unit="kcal" color={net < 0 ? C.green : C.red} />}
                        <Stat label="Protein" value={Math.round(nut.protein || 0)} unit="g" color={C.cyan} />
                        <Stat label="Carbs" value={Math.round(nut.carbs || 0)} unit="g" color={C.amber} />
                        <Stat label="Fat" value={Math.round(nut.fat || 0)} unit="g" color={C.purple} />
                      </div>
                    </div>
                  );
                })()}

                {/* Athlete log — fueling + coach notes (persisted on the activity doc) */}
                <AthleteLogEditor key={selected.id} userId={userId} activity={selected} isOwner={isOwner} />

                {/* Running Dynamics from Garmin */}
                {selected.dynamics && (() => {
                  // Idempotent unit repair — older Runalyze imports stored
                  // three FIT-native fields at raw scale (balance ×100, stride
                  // in cm, recovery in minutes). Fold on read so the card
                  // renders correctly without a backfill. Real values
                  // (balance <100, stride <5m, recovery <200h) pass through.
                  const rd = { ...selected.dynamics };
                  if (rd.groundContactBalance > 100) rd.groundContactBalance = Math.round(rd.groundContactBalance) / 100;
                  if (rd.strideLengthSensor > 5) rd.strideLengthSensor = Math.round(rd.strideLengthSensor) / 100;
                  if (rd.recoveryTime > 200) rd.recoveryTime = Math.round(rd.recoveryTime / 60);
                  // Cadence to steps-per-minute (both feet), matching cadenceSpmOf
                  // (line ~8137) so this card never disagrees with the top metrics
                  // or the form charts. Some imports stored a one-foot RPM (~83) in
                  // cadenceSpm; the <120 guard doubles it (real running floors near
                  // ~150 spm, so a true spm value is never doubled), and >250 folds
                  // a ×10 Runalyze reading back. Idempotent on already-correct data.
                  if (rd.cadenceSpm > 250) rd.cadenceSpm = Math.round(rd.cadenceSpm / 10);
                  if (rd.cadenceSpm > 0 && rd.cadenceSpm < 120) rd.cadenceSpm = Math.round(rd.cadenceSpm * 2);
                  const hasData = rd.groundContactTime || rd.verticalOscillation || rd.power || rd.trainingEffect || rd.cadenceSpm;
                  if (!hasData) return null;
                  // GCT benchmarks: <200ms excellent, 200-240 good, 240-300 fair, >300 poor
                  const gctColor = rd.groundContactTime ? (rd.groundContactTime < 220 ? C.green : rd.groundContactTime < 260 ? C.cyan : rd.groundContactTime < 300 ? C.amber : C.red) : C.textMuted;
                  // VO benchmarks: <6.5cm excellent, 6.5-8cm good, 8-10cm fair, >10cm poor
                  const voColor = rd.verticalOscillation ? (rd.verticalOscillation < 7 ? C.green : rd.verticalOscillation < 9 ? C.cyan : rd.verticalOscillation < 11 ? C.amber : C.red) : C.textMuted;
                  // GCT balance: how far from perfect 50/50
                  const gctBalColor = rd.groundContactBalance ? (Math.abs(rd.groundContactBalance - 50) < 1 ? C.green : Math.abs(rd.groundContactBalance - 50) < 2 ? C.cyan : C.amber) : C.textMuted;
                  const rightBal = rd.groundContactBalance ? (100 - rd.groundContactBalance).toFixed(1) : null;
                  return (
                    <div style={{ marginTop: 12, padding: "12px 16px", background: "#4f46e508", borderRadius: 10, border: "1px solid #4f46e525" }}>
                      <div style={{ fontSize: 11, fontWeight: 700, color: "#4f46e5", textTransform: "uppercase", letterSpacing: 1, marginBottom: 10 }}>
                        Running Dynamics <span style={{fontSize:9,fontWeight:400,color:C.textMuted}}>via Garmin Connect{rd.isIndoor ? " · indoor" : ""}</span>
                      </div>
                      <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(110px, 1fr))", gap: 12 }}>
                        {rd.groundContactTime && <Stat label="Ground Contact" value={Math.round(rd.groundContactTime)} unit="ms" color={gctColor} />}
                        {rd.groundContactBalance && rightBal && <Stat label="L/R Balance" value={`${rd.groundContactBalance.toFixed(1)} / ${rightBal}`} unit="%" color={gctBalColor} />}
                        {rd.verticalOscillation && <Stat label="Vert. Oscillation" value={rd.verticalOscillation.toFixed(1)} unit="cm" color={voColor} />}
                        {rd.verticalRatio && <Stat label="Vert. Ratio" value={rd.verticalRatio.toFixed(1)} unit="%" color={C.textDim} />}
                        {rd.cadenceSpm && <Stat label="Cadence" value={rd.cadenceSpm} unit="spm" color={rd.cadenceSpm >= 170 ? C.green : rd.cadenceSpm >= 160 ? C.cyan : C.amber} />}
                        {rd.strideLengthSensor && <Stat label="Stride Length" value={Math.round(rd.strideLengthSensor * 100)} unit="cm" color={C.green} />}
                        {rd.power && <Stat label="Avg Power" value={Math.round(rd.power)} unit="W" color={C.amber} />}
                        {rd.normalizedPower && <Stat label="Norm. Power" value={Math.round(rd.normalizedPower)} unit="W" color={C.amber} />}
                        {rd.trainingEffect && <Stat label="Aerobic TE" value={typeof rd.trainingEffect === "object" ? rd.trainingEffect.aerobic : rd.trainingEffect} unit="/5" color={C.cyan} />}
                        {rd.anaerobicTrainingEffect && <Stat label="Anaerobic TE" value={rd.anaerobicTrainingEffect} unit="/5" color={C.red} />}
                        {rd.trainingStressScore && <Stat label="TSS" value={Math.round(rd.trainingStressScore)} unit="" color={C.purple} />}
                        {rd.performanceCondition != null && <Stat label="Perf. Condition" value={rd.performanceCondition > 0 ? "+" + rd.performanceCondition : rd.performanceCondition} unit="" color={rd.performanceCondition >= 0 ? C.green : C.red} />}
                        {rd.recoveryTime && <Stat label="Recovery" value={rd.recoveryTime} unit="hrs" color={C.purple} />}
                        {rd.respirationRate && <Stat label="Respiration" value={rd.respirationRate.toFixed(0)} unit="br/min" color={C.textDim} />}
                        {rd.lactateThresholdHR && <Stat label="LTHR (Garmin)" value={rd.lactateThresholdHR} unit="bpm" color={C.red} />}
                      </div>
                    </div>
                  );
                })()}

                {/* Per-workout shoe — pick from current shoes or add a new
                    one inline. Writes gear_id (the field every footwear
                    analytic reads) via setActivityShoe; the Shoes tab and
                    race-day pick update automatically. */}
                {selRun && (() => {
                  const shoes = (gearList || []).filter(g => !g.retired);
                  const curId = selected.gear_id != null ? String(selected.gear_id) : "";
                  const curShoe = (gearList || []).find(g => String(g.id) === curId);
                  const shoeLabel = (g) => g.nickname || g.name || `${g.brand || ""} ${g.model || ""}`.trim() || `Shoe ${g.id}`;
                  if (!isOwner) {
                    return (
                      <div style={{ marginTop: 12, padding: "10px 16px", background: C.bg2, borderRadius: 10, border: `1px solid ${C.border}`, display: "flex", justifyContent: "space-between", alignItems: "center" }}>
                        <span style={{ fontSize: 11, fontWeight: 700, color: C.textDim, textTransform: "uppercase", letterSpacing: 1 }}>Shoe</span>
                        <span style={{ fontSize: 13, color: curShoe ? C.text : C.textMuted }}>{curShoe ? shoeLabel(curShoe) : "—"}</span>
                      </div>
                    );
                  }
                  const pick = (val) => {
                    if (val === "__add__") { setAddingShoe(true); return; }
                    setActivityShoe(selected.id, val || null);
                    setSelected(prev => prev ? { ...prev, gear_id: val || null, gearManual: true } : prev);
                  };
                  const commitNew = () => {
                    const id = addShoe({ name: newShoeName });
                    if (id) {
                      setActivityShoe(selected.id, id);
                      setSelected(prev => prev ? { ...prev, gear_id: id, gearManual: true } : prev);
                    }
                    setNewShoeName(""); setAddingShoe(false);
                  };
                  const ctrlStyle = { flex: 1, background: C.bg1, color: C.text, border: `1px solid ${C.border}`, borderRadius: 6, padding: "7px 9px", fontSize: 13 };
                  return (
                    <div style={{ marginTop: 12, padding: "12px 16px", background: C.bg2, borderRadius: 10, border: `1px solid ${C.border}` }}>
                      <div style={{ fontSize: 11, fontWeight: 700, color: C.textDim, textTransform: "uppercase", letterSpacing: 1, marginBottom: 8 }}>Shoe</div>
                      {!addingShoe ? (
                        <select value={curId} onChange={e => pick(e.target.value)} style={ctrlStyle}>
                          <option value="">— none —</option>
                          {shoes.map(g => <option key={g.id} value={String(g.id)}>{shoeLabel(g)}</option>)}
                          <option value="__add__">+ Add new shoe…</option>
                        </select>
                      ) : (
                        <div style={{ display: "flex", gap: 8, alignItems: "center" }}>
                          <input autoFocus value={newShoeName} placeholder="e.g. Nike Vaporfly 3"
                            onChange={e => setNewShoeName(e.target.value)}
                            onKeyDown={e => { if (e.key === "Enter") commitNew(); else if (e.key === "Escape") { setAddingShoe(false); setNewShoeName(""); } }}
                            style={ctrlStyle} />
                          <button onClick={commitNew} disabled={!newShoeName.trim()} style={{ background: C.cyan, color: "#001014", border: "none", borderRadius: 6, padding: "7px 12px", fontSize: 12, fontWeight: 700, cursor: newShoeName.trim() ? "pointer" : "default", opacity: newShoeName.trim() ? 1 : 0.5 }}>Add</button>
                          <button onClick={() => { setAddingShoe(false); setNewShoeName(""); }} style={{ background: "none", color: C.textMuted, border: `1px solid ${C.border}`, borderRadius: 6, padding: "7px 10px", fontSize: 12, cursor: "pointer" }}>✕</button>
                        </div>
                      )}
                    </div>
                  );
                })()}

                {/* Manual type reclassification — for activities Strava typed
                    wrong (a ride logged as a Run, etc.). Writes typeOverride,
                    which survives resyncs; "Auto" clears it. */}
                {isOwner && (() => {
                  const TYPE_OPTS = [
                    ["Run", "🏃 Run"], ["Ride", "🚴 Ride"], ["Workout", "🏋️ Cross-train"],
                    ["Swim", "🏊 Swim"], ["Walk", "🥾 Walk"], ["Hike", "🥾 Hike"],
                    ["WeightTraining", "🏋️ Strength"], ["Yoga", "🧘 Yoga / mobility"],
                  ];
                  const curVal = selected.typeOverride || "";
                  const ctrlStyle = { flex: 1, background: C.bg1, color: C.text, border: `1px solid ${C.border}`, borderRadius: 6, padding: "7px 9px", fontSize: 13 };
                  const pickType = (val) => {
                    setActivityType(selected.id, val || null);
                    setSelected(prev => prev ? { ...prev, typeOverride: val || null } : prev);
                  };
                  return (
                    <div style={{ marginTop: 12, padding: "12px 16px", background: C.bg2, borderRadius: 10, border: `1px solid ${C.border}` }}>
                      <div style={{ fontSize: 11, fontWeight: 700, color: C.textDim, textTransform: "uppercase", letterSpacing: 1, marginBottom: 8 }}>Activity type</div>
                      <select value={curVal} onChange={e => pickType(e.target.value)} style={ctrlStyle}>
                        <option value="">Auto — Strava: {selected.type || "Run"}</option>
                        {TYPE_OPTS.map(([val, label]) => <option key={val} value={val}>{label}</option>)}
                      </select>
                      {selected.typeOverride && (
                        <div style={{ fontSize: 10, color: C.textMuted, marginTop: 6, lineHeight: 1.5 }}>
                          Reclassified from Strava&rsquo;s &ldquo;{selected.type}&rdquo; — sticks across resyncs. {!isRun(selected) ? "Now counts as cross-training. Hit “Rebuild from activities” on the History tab to refresh the Fitness Arc." : "Now counts as a run."}
                        </div>
                      )}
                    </div>
                  );
                })()}

                {selected.description && (
                  <div style={{ marginTop: 16, padding:"12px 16px", background:C.bg2, borderRadius:8, fontSize:13, color:C.textDim, fontStyle:"italic" }}>
                    {selected.description}
                  </div>
                )}

                {/* Workout Slices — segment breakdown (warmup / reps /
                    recovery / sustained / cooldown) with the de-diluted
                    quality pace. Prefers the server-persisted slices; falls
                    back to slicing on the fly from laps/streams + a
                    current-fitness MP anchor. */}
                {(() => {
                  let sliced = sliceData[selected.id];
                  if ((!sliced || !Array.isArray(sliced.slices)) && sliceMp && window.WorkoutSlicer) {
                    const laps = lapData[selected.id], streams = streamData[selected.id];
                    if ((laps && laps.length) || (streams && streams.length)) {
                      try {
                        // Treadmill: no GPS, so slice distances are estimates —
                        // calibrate to the belt-calibrated activity total.
                        const isTreadmill = !!(selected.trainer || selected.type === "VirtualRun" || selected.dynamics?.isIndoor);
                        sliced = window.WorkoutSlicer.sliceWorkout({
                          laps, streams, mp: sliceMp, classifyPaceZone: window.RaceMath.classifyPaceZone,
                          activityDistanceM: selected.distance || null, isTreadmill,
                        });
                      } catch (_) { sliced = null; }
                    }
                  }
                  if (!sliced || !Array.isArray(sliced.slices) || sliced.slices.length < 2) return null;
                  // Apply manual interval-pace overrides (keyed by slice idx)
                  // so both the rendered splits AND the plan grading use the
                  // corrected pace.
                  const ov = paceOverrides[selected.id] || {};
                  const effSlices = Object.keys(ov).length
                    ? sliced.slices.map(s => (ov[s.idx] != null ? { ...s, avgPaceSec: ov[s.idx], _ovr: true } : s))
                    : sliced.slices;
                  const roleColor = {
                    warmup: C.textMuted, cooldown: C.textMuted, recovery: C.cyan,
                    rep: C.red, sustained: C.amber, "mp-block": C.cyan, continuous: C.green,
                  };
                  const roleLabel = {
                    warmup: "WU", cooldown: "CD", recovery: "rec",
                    rep: "rep", sustained: "block", "mp-block": "MP", continuous: "run",
                  };
                  const mss = s => !Number.isFinite(s) ? "—" : `${Math.floor(Math.round(s)/60)}:${String(Math.round(s)%60).padStart(2,"0")}`;
                  const roll = sliced.rollup;
                  // Per-rep grade vs the linked plan day's prescribed steps.
                  const planDay = planDayByActId[String(selected.id)];
                  let repGrade = null;
                  if (planDay && Array.isArray(planDay.steps) && planDay.steps.length && window.WorkoutSlicer) {
                    try { repGrade = window.WorkoutSlicer.gradeSlicesAgainstPlan(effSlices, planDay.steps); } catch (_) {}
                  }
                  const gradeCol = g => g === "A" ? C.green : g === "B" ? C.cyan : g === "C" ? C.amber : g === "D" ? C.red : C.textMuted;
                  // Map each rep/sustained slice (in order) to its grade.
                  const repGradeByIdx = {};
                  if (repGrade && repGrade.reps.length) {
                    let r = 0;
                    effSlices.forEach(s => {
                      if (s.role === "rep" || s.role === "sustained" || s.role === "mp-block") {
                        if (repGrade.reps[r]) repGradeByIdx[s.idx] = repGrade.reps[r].grade;
                        r++;
                      }
                    });
                  }
                  return (
                    <div style={{ marginTop: 16 }}>
                      <div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", marginBottom: 8 }}>
                        <div style={{ fontSize: 12, fontWeight: 700, color: C.textDim, textTransform: "uppercase", letterSpacing: "0.05em" }}>
                          Workout Slices
                        </div>
                        <div style={{ fontSize: 10, color: C.textMuted }}>
                          {sliced.source === "laps" ? "from laps" : "from stream"}
                          {sliced.calibrated ? " · belt-calibrated" : ""}
                          {roll && roll.qualityPaceSec ? ` · quality ${roll.qualityMi}mi @ ${mss(roll.qualityPaceSec)}/mi` : ""}
                        </div>
                      </div>
                      <div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
                        {effSlices.map((s, i) => {
                          const g = repGradeByIdx[s.idx];
                          const editable = isOwner && (s.role === "rep" || s.role === "sustained" || s.role === "mp-block");
                          const isEd = editingRep && editingRep.actId === selected.id && editingRep.idx === s.idx;
                          return (
                          <div key={i} style={{ display: "grid", gridTemplateColumns: "44px 1fr auto auto auto 22px", gap: 10, alignItems: "center", fontSize: 12, padding: "5px 8px", background: C.bg2, borderRadius: 6 }}>
                            <Badge label={roleLabel[s.role] || s.role} color={roleColor[s.role] || C.textMuted} />
                            <span style={{ color: C.textMuted }}>{s.zone || "—"}</span>
                            <span style={{ color: C.textDim, fontFamily: "'IBM Plex Mono',monospace" }}>{s.distMi.toFixed(2)}mi</span>
                            {isEd ? (() => {
                              const commit = () => {
                                const sec = parsePaceMSS(repPaceBuf);
                                saveRepPace(selected.id, s.idx, repPaceBuf.trim() === "" ? null : sec);
                                setEditingRep(null); setRepPaceBuf("");
                              };
                              return (
                                <input autoFocus value={repPaceBuf} placeholder="m:ss"
                                  onChange={e => setRepPaceBuf(e.target.value)}
                                  onBlur={commit}
                                  onKeyDown={e => { if (e.key === "Enter") commit(); else if (e.key === "Escape") { setEditingRep(null); setRepPaceBuf(""); } }}
                                  style={{ width: 50, justifySelf: "end", background: C.bg1, color: C.text, border: `1px solid ${C.cyan}`, borderRadius: 4, padding: "2px 4px", fontSize: 12, fontFamily: "'IBM Plex Mono',monospace", textAlign: "right" }} />
                              );
                            })() : (
                              <span
                                onClick={editable ? () => { setEditingRep({ actId: selected.id, idx: s.idx }); setRepPaceBuf(fmtPaceMSS(s.avgPaceSec)); } : undefined}
                                title={editable ? (s._ovr ? "Manually set — click to edit (blank to reset)" : "Click to override pace") : undefined}
                                style={{ color: s._ovr ? C.cyan : (roleColor[s.role] || C.text), fontWeight: 600, fontFamily: "'IBM Plex Mono',monospace", minWidth: 48, textAlign: "right", cursor: editable ? "pointer" : "default", borderBottom: editable ? `1px dashed ${s._ovr ? C.cyan : C.border}` : "none" }}>
                                {mss(s.avgPaceSec)}/mi{s._ovr ? "*" : ""}
                              </span>
                            )}
                            <span style={{ color: C.textMuted, fontFamily: "'IBM Plex Mono',monospace", minWidth: 52, textAlign: "right" }}>{s.avgHr ? `${s.avgHr}bpm` : "—"}</span>
                            <span style={{ color: gradeCol(g), fontWeight: 800, fontFamily: "'Space Grotesk',sans-serif", textAlign: "right" }}>{g || ""}</span>
                          </div>
                          );
                        })}
                      </div>
                      {isOwner && effSlices.some(s => s.role === "rep" || s.role === "sustained" || s.role === "mp-block") && (
                        <div style={{ fontSize: 9, color: C.textMuted, marginTop: 4 }}>Tap a rep / block pace to correct it (blank + Enter resets). Overrides feed the grade.</div>
                      )}
                      {repGrade && repGrade.overall && (
                        <div style={{ fontSize: 10, color: C.textMuted, marginTop: 6 }}>
                          Per-rep grading vs plan: prescribed {repGrade.prescribed} reps{repGrade.reps.find(r => r.plannedPaceSec) ? ` @ ${mss(repGrade.reps.find(r => r.plannedPaceSec).plannedPaceSec)}/mi` : ""}, ran {repGrade.ran} — overall <span style={{ color: gradeCol(repGrade.overall), fontWeight: 700 }}>{repGrade.overall}</span>
                        </div>
                      )}
                    </div>
                  );
                })()}

                {/* Detailed Lap/Split Analysis */}
                {(() => {
                  const isTreadmill = selected.type === "VirtualRun" || selected.dynamics?.isIndoor;
                  const laps = lapData[selected.id];
                  if (loadingLaps === selected.id) return (
                    <div style={{ marginTop: 16, fontSize: 12, color: C.textMuted, textAlign: "center", padding: 20 }}>Loading lap data...</div>
                  );
                  if (!laps || laps.length === 0) {
                    // For treadmill activities with no Strava laps, synthesize mile splits from overall data
                    if (isTreadmill && selected.distance > 100 && selected.moving_time > 0) {
                      const totalMiles = selected.distance / 1609.34;
                      const avgPaceSec = selected.moving_time / totalMiles;
                      const synthLaps = [];
                      for (let i = 0; i < Math.floor(totalMiles); i++) {
                        synthLaps.push({
                          lap_index: i + 1, distance: 1609.34, moving_time: avgPaceSec,
                          average_heartrate: selected.average_heartrate || null,
                          total_elevation_gain: 0, _synthetic: true,
                        });
                      }
                      const rem = totalMiles - Math.floor(totalMiles);
                      if (rem > 0.05) synthLaps.push({
                        lap_index: synthLaps.length + 1, distance: rem * 1609.34, moving_time: avgPaceSec * rem,
                        average_heartrate: selected.average_heartrate || null,
                        total_elevation_gain: 0, _synthetic: true,
                      });
                      if (synthLaps.length > 0) {
                        return (
                          <div style={{ marginTop: 16 }}>
                            <div style={{ display:"flex", justifyContent:"space-between", alignItems:"center", marginBottom: 10 }}>
                              <div style={{ fontSize: 13, fontWeight: 600, color: C.textDim }}>
                                Pace Splits — Treadmill
                                <span style={{ marginLeft: 8, fontSize: 10, padding:"2px 6px", borderRadius:4, background:C.purple+"20", color:C.purple }}>INDOOR</span>
                              </div>
                              <button onClick={() => fetchLaps(selected.id)} style={{ padding:"4px 10px", borderRadius:5, border:`1px solid ${C.border}`, cursor:"pointer", background:C.bg2, color:C.cyan, fontSize:10 }}>Fetch Strava Splits</button>
                            </div>
                            <div style={{ background: C.bg2, borderRadius: 8, overflow: "hidden" }}>
                              <div style={{ display:"grid", gridTemplateColumns:"40px 70px 70px 70px 65px", gap:8, padding:"6px 12px", fontSize:10, color:C.textMuted, borderBottom:`1px solid ${C.border}` }}>
                                <span>Lap</span><span style={{textAlign:"right"}}>Dist</span><span style={{textAlign:"right"}}>Time</span><span style={{textAlign:"right"}}>Pace</span><span style={{textAlign:"right"}}>HR</span>
                              </div>
                              {synthLaps.map((l, li) => {
                                const lapDist = l.distance / 1609.34;
                                const lapPace = l.moving_time / lapDist;
                                const m = Math.floor(lapPace / 60), s = Math.round(lapPace % 60);
                                return (
                                  <div key={li} style={{ display:"grid", gridTemplateColumns:"40px 70px 70px 70px 65px", gap:8, padding:"7px 12px", borderBottom: li < synthLaps.length-1 ? `1px solid ${C.border}` : "none", fontSize:12 }}>
                                    <span style={{color:C.textMuted}}>{l.lap_index}</span>
                                    <span style={{textAlign:"right",color:C.text}}>{lapDist.toFixed(2)}mi</span>
                                    <span style={{textAlign:"right",color:C.text}}>{Math.floor(l.moving_time/60)}:{String(Math.round(l.moving_time%60)).padStart(2,"0")}</span>
                                    <span style={{textAlign:"right",fontWeight:700,color:C.amber}}>{m}:{String(s).padStart(2,"0")}</span>
                                    <span style={{textAlign:"right",color:C.pink}}>{l.average_heartrate ? Math.round(l.average_heartrate) : "--"}</span>
                                  </div>
                                );
                              })}
                            </div>
                            <div style={{ fontSize: 10, color: C.textMuted, marginTop: 6 }}>
                              Mile splits estimated from overall pace — tap "Fetch Strava Splits" for actual lap data if recorded
                            </div>
                          </div>
                        );
                      }
                    }
                    return (
                      <div style={{ marginTop: 16 }}>
                        <button onClick={() => fetchLaps(selected.id)} style={{
                          padding: "8px 16px", borderRadius: 6, border: `1px solid ${C.border}`, cursor: "pointer",
                          background: C.bg2, color: C.cyan, fontSize: 11, fontFamily: "'IBM Plex Mono',monospace",
                        }}>Load Detailed Splits</button>
                      </div>
                    );
                  }

                  // Analyze laps
                  const maxPace = Math.max(...laps.filter(l => l.distance > 100).map(l => l.moving_time / (l.distance/1609.34)));
                  const minPace = Math.min(...laps.filter(l => l.distance > 100).map(l => l.moving_time / (l.distance/1609.34)));
                  const avgHR = laps.filter(l => l.average_heartrate).length > 0
                    ? laps.filter(l => l.average_heartrate).reduce((s,l) => s + l.average_heartrate, 0) / laps.filter(l => l.average_heartrate).length : null;

                  // Detect workout type: intervals (high pace variance), tempo (consistent moderate), easy (slow consistent)
                  const paceVariance = maxPace - minPace;
                  const isInterval = paceVariance > 90; // > 1:30/mi variance = intervals
                  const isTempo = !isInterval && avgHR && avgHR > 155;

                  // Hill-adjusted pace: laps with elevation gain > threshold get a "GAP" (Grade Adjusted Pace)
                  const hasElevation = laps.some(l => l.total_elevation_gain > 5);

                  return (
                    <div style={{ marginTop: 16 }}>
                      <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 10 }}>
                        <div style={{ fontSize: 13, fontWeight: 600, color: C.textDim }}>
                          Lap Splits ({laps.length} laps)
                          {isInterval && <span style={{ marginLeft: 8, fontSize: 10, padding: "2px 6px", borderRadius: 4, background: C.amber + "20", color: C.amber }}>INTERVALS</span>}
                          {isTempo && <span style={{ marginLeft: 8, fontSize: 10, padding: "2px 6px", borderRadius: 4, background: C.purple + "20", color: C.purple }}>TEMPO</span>}
                        </div>
                        {paceVariance > 30 && <span style={{ fontSize: 10, color: C.textMuted }}>Pace range: {Math.round(paceVariance)}s/mi</span>}
                      </div>

                      {/* Lap table */}
                      <div style={{ overflowX: "auto" }}>
                        <div style={{
                          display: "grid", gridTemplateColumns: hasElevation ? "40px 70px 70px 70px 65px 70px 60px" : "40px 70px 70px 70px 65px 70px",
                          gap: 6, padding: "8px 10px", borderBottom: `1px solid ${C.border}`,
                          fontSize: 10, color: C.textMuted, textTransform: "uppercase",
                        }}>
                          <span>Lap</span><span style={{textAlign:"right"}}>Dist</span>
                          <span style={{textAlign:"right"}}>Time</span><span style={{textAlign:"right"}}>Pace</span>
                          <span style={{textAlign:"right"}}>HR</span><span style={{textAlign:"right"}}>Cadence</span>
                          {hasElevation && <span style={{textAlign:"right"}}>GAP</span>}
                        </div>
                        {laps.map((lap, li) => {
                          const lapDist = lap.distance / 1609.34;
                          const lapPace = lap.distance > 100 ? lap.moving_time / lapDist : 0;
                          const lapHR = lap.average_heartrate || null;
                          // Grade Adjusted Pace: add ~15s/mi per 1% grade
                          const grade = lap.distance > 50 && lap.total_elevation_gain != null
                            ? (lap.total_elevation_gain / lap.distance) * 100 : 0;
                          const gapPace = lapPace > 0 ? lapPace - (grade * 15) : 0;

                          // Color code: fastest laps green, slowest red
                          const paceNorm = maxPace > minPace ? (lapPace - minPace) / (maxPace - minPace) : 0.5;
                          const paceColor = lapPace === 0 ? C.textMuted : paceNorm < 0.33 ? C.green : paceNorm < 0.66 ? C.amber : C.red;

                          return (
                            <div key={li} style={{
                              display: "grid", gridTemplateColumns: hasElevation ? "40px 70px 70px 70px 65px 70px 60px" : "40px 70px 70px 70px 65px 70px",
                              gap: 6, padding: "6px 10px",
                              borderBottom: li < laps.length-1 ? `1px solid ${C.border}` : "none",
                              fontSize: 12, background: isInterval && paceNorm < 0.33 ? C.green + "08" : "transparent",
                            }}>
                              <span style={{ color: C.textMuted, fontWeight: 600 }}>{li + 1}</span>
                              <span style={{ textAlign: "right", color: C.text, fontFamily: "'IBM Plex Mono',monospace" }}>
                                {lapDist > 0.01 ? lapDist.toFixed(2) : "--"}
                              </span>
                              <span style={{ textAlign: "right", color: C.text, fontFamily: "'IBM Plex Mono',monospace" }}>
                                {fmt.duration(lap.moving_time)}
                              </span>
                              <span style={{ textAlign: "right", color: paceColor, fontWeight: 700, fontFamily: "'IBM Plex Mono',monospace" }}>
                                {lapPace > 0 ? fmt.pace(lapPace).split(" ")[0] : "--"}
                              </span>
                              <span style={{ textAlign: "right", color: lapHR ? C.pink : C.textMuted, fontFamily: "'IBM Plex Mono',monospace" }}>
                                {lapHR ? Math.round(lapHR) : "--"}
                              </span>
                              <span style={{ textAlign: "right", color: C.textDim, fontFamily: "'IBM Plex Mono',monospace" }}>
                                {lap.average_cadence ? Math.round(lap.average_cadence * 2) : "--"}
                              </span>
                              {hasElevation && (
                                <span style={{ textAlign: "right", color: gapPace > 0 && grade > 0.5 ? C.green : C.textDim, fontFamily: "'IBM Plex Mono',monospace" }}>
                                  {gapPace > 0 && grade > 0.5 ? fmt.pace(gapPace).split(" ")[0] : "--"}
                                </span>
                              )}
                            </div>
                          );
                        })}
                      </div>

                      {/* Lap analysis chart — pace + HR overlay */}
                      {laps.length >= 2 && (
                        <div style={{ marginTop: 14 }}>
                          <ResponsiveContainer width="100%" height={160}>
                            <ComposedChart data={laps.map((l, i) => ({
                              lap: i + 1,
                              pace: l.distance > 100 ? Math.round(l.moving_time / (l.distance/1609.34)) : null,
                              hr: l.average_heartrate || null,
                              elev: l.total_elevation_gain || 0,
                            }))}>
                              <CartesianGrid strokeDasharray="3 3" stroke={C.border} vertical={false} />
                              <XAxis dataKey="lap" tick={{ fill: C.textMuted, fontSize: 10 }} axisLine={false} tickLine={false} />
                              <YAxis yAxisId="pace" domain={["dataMin - 10", "dataMax + 10"]} reversed tick={{ fill: C.textMuted, fontSize: 10 }}
                                tickFormatter={s => { const m=Math.floor(s/60); return `${m}:${String(Math.round(s%60)).padStart(2,"0")}`; }}
                                axisLine={false} tickLine={false} width={40} />
                              <YAxis yAxisId="hr" orientation="right" domain={["dataMin - 5", "dataMax + 5"]}
                                tick={{ fill: C.pink, fontSize: 10 }} axisLine={false} tickLine={false} width={35} />
                              <Tooltip content={({ active, payload }) => {
                                if (!active || !payload || !payload.length) return null;
                                return (
                                  <div style={{ background:C.bg, border:`1px solid ${C.border}`, borderRadius:8, padding:"6px 10px", fontSize:11 }}>
                                    {payload.map((p,i) => p.value != null && (
                                      <div key={i} style={{ color:p.color }}>
                                        {p.name}: {p.name === "pace" ? fmt.pace(p.value).split(" ")[0] : p.name === "hr" ? Math.round(p.value) + " bpm" : p.value}
                                      </div>
                                    ))}
                                  </div>
                                );
                              }} />
                              <Bar yAxisId="pace" dataKey="pace" fill={C.cyan + "40"} radius={[3,3,0,0]} name="pace" />
                              <Line yAxisId="hr" type="monotone" dataKey="hr" stroke={C.pink} strokeWidth={2} dot={{ r: 3, fill: C.pink }} name="hr" />
                            </ComposedChart>
                          </ResponsiveContainer>
                        </div>
                      )}

                      {/* ── Continuous Pace & HR Graphs (from streams) ── */}
                      {(() => {
                        const streams = streamData[selected.id];
                        if (!streams || streams.length < 5) return null;

                        // Build chart data: convert velocity (m/s) to pace (sec/mi), distance to miles
                        const chartPts = streams.filter(p => p.v > 0.5).map(p => ({
                          dist: p.d ? +(p.d / 1609.34).toFixed(2) : 0,
                          time: p.t,
                          pace: p.v > 0 ? Math.round(1609.34 / p.v) : null,
                          hr: p.hr || null,
                          alt: p.alt != null ? Math.round(p.alt * 3.281) : null, // m→ft
                          cad: p.cad ? p.cad * 2 : null,
                          grade: p.grade || 0,
                        }));

                        if (chartPts.length < 5) return null;

                        // Smooth pace with a small rolling-median window
                        // (3 samples) — large enough to absorb GPS noise on
                        // easy-pace stretches, small enough to preserve
                        // short bursts like strides. The previous 7-sample
                        // mean averaged stride peaks (5:50/mi) with the
                        // surrounding easy pace (10:00/mi) and rendered
                        // them as a flat 8:13 line, hiding the actual
                        // intensity from the chart.
                        const smoothed = chartPts.map((pt, i) => {
                          const win = 3;
                          const start = Math.max(0, i - Math.floor(win/2));
                          const end = Math.min(chartPts.length, i + Math.ceil(win/2));
                          const slice = chartPts.slice(start, end).filter(p => p.pace && p.pace < 1200).map(p => p.pace).sort((a, b) => a - b);
                          const avgPace = slice.length > 0 ? slice[Math.floor(slice.length / 2)] : pt.pace;
                          return { ...pt, pace: avgPace };
                        });

                        // Calculate averages for reference lines
                        const validPaces = smoothed.filter(p => p.pace && p.pace > 180 && p.pace < 1200);
                        const avgPace = validPaces.length > 0 ? Math.round(validPaces.reduce((s,p) => s + p.pace, 0) / validPaces.length) : null;
                        const validHR = smoothed.filter(p => p.hr);
                        const avgHR = validHR.length > 0 ? Math.round(validHR.reduce((s,p) => s + p.hr, 0) / validHR.length) : null;

                        // Pace Y domain — compute from RAW paces (not the
                        // smoothed line) so the axis extends to capture
                        // peaks even if smoothing dampens the line a bit.
                        // Without this, a single fast stride at 5:50/mi
                        // smoothed to 7:00 would set the axis top at 6:40
                        // and clip everything else.
                        const rawPaces = chartPts
                          .map(p => p.pace)
                          .filter(p => Number.isFinite(p) && p > 180 && p < 1200);
                        const paceMin = rawPaces.length ? Math.max(180, Math.min(...rawPaces) - 20) : 180;
                        const paceMax = rawPaces.length ? Math.min(1200, Math.max(...rawPaces) + 20) : 1200;

                        const fmtPaceTick = s => { const m=Math.floor(s/60); return `${m}:${String(Math.round(s%60)).padStart(2,"0")}`; };

                        return (
                          <div style={{ marginTop: 16 }}>
                            {/* Pace Graph */}
                            <div style={{ fontSize: 12, fontWeight: 600, color: C.cyan, marginBottom: 6 }}>Pace</div>
                            <ResponsiveContainer width="100%" height={140}>
                              <AreaChart data={smoothed}>
                                <defs>
                                  <linearGradient id="paceGrad" x1="0" y1="0" x2="0" y2="1">
                                    <stop offset="5%" stopColor={C.cyan} stopOpacity={0.3}/>
                                    <stop offset="95%" stopColor={C.cyan} stopOpacity={0.02}/>
                                  </linearGradient>
                                </defs>
                                <CartesianGrid strokeDasharray="3 3" stroke={C.border} vertical={false} />
                                <XAxis dataKey="dist" tick={{ fill: C.textMuted, fontSize: 9 }} axisLine={false} tickLine={false}
                                  tickFormatter={v => v.toFixed(1) + " mi"} interval={Math.max(1, Math.floor(smoothed.length / 8))} />
                                <YAxis domain={[paceMin, paceMax]} reversed tick={{ fill: C.textMuted, fontSize: 9 }}
                                  tickFormatter={fmtPaceTick} axisLine={false} tickLine={false} width={40} />
                                <Tooltip content={({ active, payload }) => {
                                  if (!active || !payload || !payload.length) return null;
                                  const d = payload[0].payload;
                                  return (
                                    <div style={{ background: C.bg, border: `1px solid ${C.border}`, borderRadius: 8, padding: "6px 10px", fontSize: 11 }}>
                                      <div style={{ color: C.cyan }}>Pace: {d.pace ? fmtPaceTick(d.pace) + "/mi" : "--"}</div>
                                      <div style={{ color: C.textMuted }}>Dist: {d.dist.toFixed(2)} mi</div>
                                      {d.alt != null && <div style={{ color: C.textMuted }}>Elev: {d.alt} ft</div>}
                                    </div>
                                  );
                                }} />
                                {avgPace && <ReferenceLine y={avgPace} stroke={C.cyan} strokeDasharray="4 4" strokeOpacity={0.5}
                                  label={{ value: `Avg ${fmtPaceTick(avgPace)}`, fill: C.cyan, fontSize: 9, position: "right" }} />}
                                <Area type="monotone" dataKey="pace" stroke={C.cyan} fill="url(#paceGrad)" strokeWidth={1.5} dot={false} />
                              </AreaChart>
                            </ResponsiveContainer>

                            {/* HR Graph */}
                            {validHR.length > 5 && (
                              <div style={{ marginTop: 12 }}>
                                <div style={{ fontSize: 12, fontWeight: 600, color: C.pink, marginBottom: 6 }}>Heart Rate</div>
                                <ResponsiveContainer width="100%" height={140}>
                                  <AreaChart data={smoothed}>
                                    <defs>
                                      <linearGradient id="hrGrad" x1="0" y1="0" x2="0" y2="1">
                                        <stop offset="5%" stopColor={C.pink} stopOpacity={0.3}/>
                                        <stop offset="95%" stopColor={C.pink} stopOpacity={0.02}/>
                                      </linearGradient>
                                    </defs>
                                    <CartesianGrid strokeDasharray="3 3" stroke={C.border} vertical={false} />
                                    <XAxis dataKey="dist" tick={{ fill: C.textMuted, fontSize: 9 }} axisLine={false} tickLine={false}
                                      tickFormatter={v => v.toFixed(1) + " mi"} interval={Math.max(1, Math.floor(smoothed.length / 8))} />
                                    <YAxis domain={["dataMin - 5", "dataMax + 5"]}
                                      tick={{ fill: C.textMuted, fontSize: 9 }} axisLine={false} tickLine={false} width={35} />
                                    <Tooltip content={({ active, payload }) => {
                                      if (!active || !payload || !payload.length) return null;
                                      const d = payload[0].payload;
                                      return (
                                        <div style={{ background: C.bg, border: `1px solid ${C.border}`, borderRadius: 8, padding: "6px 10px", fontSize: 11 }}>
                                          <div style={{ color: C.pink }}>HR: {d.hr ? d.hr + " bpm" : "--"}</div>
                                          <div style={{ color: C.textMuted }}>Dist: {d.dist.toFixed(2)} mi</div>
                                        </div>
                                      );
                                    }} />
                                    {avgHR && <ReferenceLine y={avgHR} stroke={C.pink} strokeDasharray="4 4" strokeOpacity={0.5}
                                      label={{ value: `Avg ${avgHR}`, fill: C.pink, fontSize: 9, position: "right" }} />}
                                    <Area type="monotone" dataKey="hr" stroke={C.pink} fill="url(#hrGrad)" strokeWidth={1.5} dot={false} />
                                  </AreaChart>
                                </ResponsiveContainer>
                              </div>
                            )}

                            {/* Elevation Profile */}
                            {smoothed.some(p => p.alt != null) && (
                              <div style={{ marginTop: 12 }}>
                                <div style={{ fontSize: 12, fontWeight: 600, color: C.green, marginBottom: 6 }}>Elevation</div>
                                <ResponsiveContainer width="100%" height={100}>
                                  <AreaChart data={smoothed}>
                                    <defs>
                                      <linearGradient id="elevGrad" x1="0" y1="0" x2="0" y2="1">
                                        <stop offset="5%" stopColor={C.green} stopOpacity={0.3}/>
                                        <stop offset="95%" stopColor={C.green} stopOpacity={0.02}/>
                                      </linearGradient>
                                    </defs>
                                    <CartesianGrid strokeDasharray="3 3" stroke={C.border} vertical={false} />
                                    <XAxis dataKey="dist" tick={{ fill: C.textMuted, fontSize: 9 }} axisLine={false} tickLine={false}
                                      tickFormatter={v => v.toFixed(1) + " mi"} interval={Math.max(1, Math.floor(smoothed.length / 8))} />
                                    <YAxis domain={["dataMin - 5", "dataMax + 15"]}
                                      tick={{ fill: C.textMuted, fontSize: 9 }} axisLine={false} tickLine={false} width={35}
                                      tickFormatter={v => Math.round(v) + "ft"} />
                                    <Tooltip content={({ active, payload }) => {
                                      if (!active || !payload || !payload.length) return null;
                                      const d = payload[0].payload;
                                      return (
                                        <div style={{ background: C.bg, border: `1px solid ${C.border}`, borderRadius: 8, padding: "6px 10px", fontSize: 11 }}>
                                          <div style={{ color: C.green }}>Elevation: {d.alt != null ? d.alt + " ft" : "--"}</div>
                                          <div style={{ color: C.textMuted }}>Grade: {d.grade}%</div>
                                          <div style={{ color: C.textMuted }}>Dist: {d.dist.toFixed(2)} mi</div>
                                        </div>
                                      );
                                    }} />
                                    <Area type="monotone" dataKey="alt" stroke={C.green} fill="url(#elevGrad)" strokeWidth={1.5} dot={false} />
                                  </AreaChart>
                                </ResponsiveContainer>
                              </div>
                            )}
                          </div>
                        );
                      })()}

                      {/* Workout type analysis */}
                      {isInterval && (() => {
                        const fastLaps = laps.filter(l => {
                          if (l.distance < 100) return false;
                          const p = l.moving_time / (l.distance/1609.34);
                          return p < minPace + (maxPace - minPace) * 0.4;
                        });
                        const restLaps = laps.filter(l => {
                          if (l.distance < 100) return false;
                          const p = l.moving_time / (l.distance/1609.34);
                          return p >= minPace + (maxPace - minPace) * 0.6;
                        });
                        const avgFast = fastLaps.length > 0 ? fastLaps.reduce((s,l) => s + l.moving_time/(l.distance/1609.34), 0) / fastLaps.length : 0;
                        const avgRest = restLaps.length > 0 ? restLaps.reduce((s,l) => s + l.moving_time/(l.distance/1609.34), 0) / restLaps.length : 0;
                        const avgFastHR = fastLaps.filter(l=>l.average_heartrate).length > 0
                          ? fastLaps.filter(l=>l.average_heartrate).reduce((s,l)=>s+l.average_heartrate,0)/fastLaps.filter(l=>l.average_heartrate).length : null;
                        return (
                          <div style={{ marginTop: 12, padding: "10px 14px", background: C.bg2, borderRadius: 8, fontSize: 11 }}>
                            <div style={{ fontWeight: 600, color: C.amber, marginBottom: 6 }}>Interval Analysis</div>
                            <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 12 }}>
                              <div>
                                <span style={{ color: C.textMuted }}>Fast laps: </span>
                                <span style={{ color: C.green, fontWeight: 700 }}>{fastLaps.length}x</span>
                                <span style={{ color: C.textMuted }}> avg </span>
                                <span style={{ color: C.green, fontWeight: 700 }}>{avgFast > 0 ? fmt.pace(avgFast).split(" ")[0] : "--"}/mi</span>
                              </div>
                              <div>
                                <span style={{ color: C.textMuted }}>Recovery: </span>
                                <span style={{ color: C.textDim, fontWeight: 700 }}>{restLaps.length}x</span>
                                <span style={{ color: C.textMuted }}> avg </span>
                                <span style={{ color: C.textDim, fontWeight: 700 }}>{avgRest > 0 ? fmt.pace(avgRest).split(" ")[0] : "--"}/mi</span>
                              </div>
                              {avgFastHR && (
                                <div>
                                  <span style={{ color: C.textMuted }}>Interval HR: </span>
                                  <span style={{ color: C.pink, fontWeight: 700 }}>{Math.round(avgFastHR)} bpm</span>
                                </div>
                              )}
                            </div>
                          </div>
                        );
                      })()}

                      {/* Hill analysis */}
                      {hasElevation && (() => {
                        const hillLaps = laps.filter(l => l.distance > 50 && l.total_elevation_gain > 5);
                        const flatLaps = laps.filter(l => l.distance > 100 && (l.total_elevation_gain || 0) <= 2);
                        if (hillLaps.length === 0 || flatLaps.length === 0) return null;
                        const avgHillPace = hillLaps.reduce((s,l) => s + l.moving_time/(l.distance/1609.34), 0) / hillLaps.length;
                        const avgFlatPace = flatLaps.reduce((s,l) => s + l.moving_time/(l.distance/1609.34), 0) / flatLaps.length;
                        const hillSlowdown = avgHillPace - avgFlatPace;
                        return (
                          <div style={{ marginTop: 8, padding: "10px 14px", background: C.bg2, borderRadius: 8, fontSize: 11 }}>
                            <div style={{ fontWeight: 600, color: C.green, marginBottom: 6 }}>Hill Impact</div>
                            <div style={{ display: "flex", gap: 20 }}>
                              <span><span style={{ color: C.textMuted }}>Flat pace: </span><span style={{ fontWeight: 700, color: C.text }}>{fmt.pace(avgFlatPace).split(" ")[0]}/mi</span></span>
                              <span><span style={{ color: C.textMuted }}>Hill pace: </span><span style={{ fontWeight: 700, color: C.amber }}>{fmt.pace(avgHillPace).split(" ")[0]}/mi</span></span>
                              <span><span style={{ color: C.textMuted }}>Slowdown: </span><span style={{ fontWeight: 700, color: C.red }}>+{Math.round(hillSlowdown)}s/mi</span></span>
                            </div>
                          </div>
                        );
                      })()}
                    </div>
                  );
                })()}

                {/* AI Coach Insight */}
                {isRun(selected) && (() => {
                  const insight = insightData[selected.id];
                  const gradeColors = { "A+": C.green, "A": C.green, "A-": C.green, "B+": C.cyan, "B": C.cyan, "B-": C.amber, "C+": C.amber, "C": C.red };
                  if (!insight) return (
                    <div style={{ marginTop: 16 }}>
                      <button onClick={() => loadInsight(selected.id)} style={{
                        width: "100%", padding: "12px 16px", borderRadius: 10, cursor: "pointer",
                        background: `linear-gradient(135deg, ${C.purple}15, ${C.cyan}15)`,
                        border: `1px solid ${C.purple}30`, color: C.text, fontSize: 13, fontWeight: 600,
                        fontFamily: "'Space Grotesk', sans-serif",
                      }}>
                        Generate AI Coach Insight
                      </button>
                    </div>
                  );
                  if (insight.status === "generating") return (
                    <div style={{ marginTop: 16, padding: "20px", background: `linear-gradient(135deg, ${C.purple}08, ${C.cyan}08)`,
                      borderRadius: 12, border: `1px solid ${C.purple}20`, textAlign: "center" }}>
                      <div style={{ fontSize: 13, color: C.textDim, marginBottom: 8 }}>Analyzing your workout...</div>
                      <div style={{ fontSize: 11, color: C.textMuted }}>AI coach is reviewing splits, HR, pace, and health data</div>
                    </div>
                  );
                  if (insight.status === "failed") return (
                    <div style={{ marginTop: 16, padding: "12px 16px", background: C.bg2, borderRadius: 10, border: `1px solid ${C.red}30` }}>
                      <div style={{ fontSize: 12, color: C.red, marginBottom: 8 }}>Insight generation failed</div>
                      <button onClick={() => loadInsight(selected.id, true)} style={{
                        padding: "6px 12px", borderRadius: 6, cursor: "pointer", fontSize: 11,
                        background: "transparent", border: `1px solid ${C.purple}40`, color: C.purple,
                      }}>Retry</button>
                    </div>
                  );
                  return (
                    <div style={{ marginTop: 16, borderRadius: 12, overflow: "hidden",
                      background: `linear-gradient(135deg, ${C.purple}08, ${C.cyan}08)`,
                      border: `1px solid ${C.purple}25` }}>
                      {/* Header with grade + workout type badge */}
                      <div style={{ padding: "14px 16px", display: "flex", justifyContent: "space-between", alignItems: "center",
                        borderBottom: `1px solid ${C.border}` }}>
                        <div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
                          <span style={{ fontSize: 14, fontWeight: 700, fontFamily: "'Space Grotesk', sans-serif", color: C.text }}>
                            Coach Analysis
                          </span>
                          {insight.workoutType && (
                            <span style={{
                              fontSize: 10, fontWeight: 700, padding: "2px 8px", borderRadius: 4,
                              background: insight.workoutType.includes("Strides") ? `${C.purple}20` : `${C.cyan}15`,
                              color: insight.workoutType.includes("Strides") ? C.purple : C.cyan,
                              border: `1px solid ${insight.workoutType.includes("Strides") ? C.purple : C.cyan}30`,
                              textTransform: "uppercase", letterSpacing: 0.5,
                            }}>{insight.workoutType}</span>
                          )}
                        </div>
                        <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
                          {insight.grade && insight.grade !== "?" && (
                            <span style={{
                              fontSize: 18, fontWeight: 800, fontFamily: "'Space Grotesk', sans-serif",
                              color: gradeColors[insight.grade] || C.text,
                              textShadow: `0 0 12px ${gradeColors[insight.grade] || C.text}40`,
                            }}>{insight.grade}</span>
                          )}
                          <button onClick={() => loadInsight(selected.id, true)} title="Regenerate"
                            style={{ background: "none", border: "none", cursor: "pointer", color: C.textMuted, fontSize: 14, padding: 4 }}>
                            ↻
                          </button>
                        </div>
                      </div>

                      {/* Component grade breakdown — shown when stride/mixed workout detected */}
                      {(insight.aerobicExecutionGrade || insight.strideExecutionGrade) && (
                        <div style={{ padding: "10px 16px", borderBottom: `1px solid ${C.border}`,
                          display: "flex", gap: 12, flexWrap: "wrap" }}>
                          {insight.aerobicExecutionGrade && (
                            <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
                              <span style={{ fontSize: 10, color: C.textMuted, textTransform: "uppercase", letterSpacing: 0.5 }}>Aerobic</span>
                              <span style={{ fontSize: 13, fontWeight: 800, fontFamily: "'Space Grotesk', sans-serif",
                                color: gradeColors[insight.aerobicExecutionGrade] || C.text }}>
                                {insight.aerobicExecutionGrade}
                              </span>
                            </div>
                          )}
                          {insight.strideExecutionGrade && insight.strideExecutionGrade !== "null" && (
                            <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
                              <span style={{ fontSize: 10, color: C.textMuted, textTransform: "uppercase", letterSpacing: 0.5 }}>Strides</span>
                              <span style={{ fontSize: 13, fontWeight: 800, fontFamily: "'Space Grotesk', sans-serif",
                                color: gradeColors[insight.strideExecutionGrade] || C.text }}>
                                {insight.strideExecutionGrade}
                              </span>
                            </div>
                          )}
                          {insight.z1Compliance && (
                            <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
                              <span style={{ fontSize: 10, color: C.textMuted, textTransform: "uppercase", letterSpacing: 0.5 }}>Z1 compliance</span>
                              <span style={{ fontSize: 12, color: C.green, fontWeight: 600 }}>{insight.z1Compliance}</span>
                            </div>
                          )}
                        </div>
                      )}

                      {/* Summary */}
                      <div style={{ padding: "12px 16px", fontSize: 13, color: C.text, lineHeight: 1.6 }}>
                        {insight.summary}
                      </div>

                      {/* Highlights & Improvements */}
                      <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 0 }}>
                        {insight.highlights && insight.highlights.length > 0 && (
                          <div style={{ padding: "10px 16px", borderTop: `1px solid ${C.border}`, borderRight: `1px solid ${C.border}` }}>
                            <div style={{ fontSize: 10, fontWeight: 700, color: C.green, textTransform: "uppercase", letterSpacing: 1, marginBottom: 6 }}>Highlights</div>
                            {insight.highlights.map((h, i) => (
                              <div key={i} style={{ fontSize: 12, color: C.textDim, marginBottom: 4, paddingLeft: 8,
                                borderLeft: `2px solid ${C.green}40` }}>{h}</div>
                            ))}
                          </div>
                        )}
                        {insight.improvements && insight.improvements.length > 0 && (
                          <div style={{ padding: "10px 16px", borderTop: `1px solid ${C.border}` }}>
                            <div style={{ fontSize: 10, fontWeight: 700, color: C.amber, textTransform: "uppercase", letterSpacing: 1, marginBottom: 6 }}>Improve</div>
                            {insight.improvements.map((h, i) => (
                              <div key={i} style={{ fontSize: 12, color: C.textDim, marginBottom: 4, paddingLeft: 8,
                                borderLeft: `2px solid ${C.amber}40` }}>{h}</div>
                            ))}
                          </div>
                        )}
                      </div>

                      {/* Detail sections */}
                      <div style={{ padding: "0 16px 12px" }}>
                        {insight.hrAnalysis && (
                          <div style={{ padding: "8px 0", borderTop: `1px solid ${C.border}` }}>
                            <span style={{ fontSize: 10, fontWeight: 700, color: C.pink, textTransform: "uppercase", letterSpacing: 1 }}>Heart Rate </span>
                            <span style={{ fontSize: 12, color: C.textDim }}>{insight.hrAnalysis}</span>
                          </div>
                        )}
                        {insight.paceAnalysis && (
                          <div style={{ padding: "8px 0", borderTop: `1px solid ${C.border}` }}>
                            <span style={{ fontSize: 10, fontWeight: 700, color: C.amber, textTransform: "uppercase", letterSpacing: 1 }}>Pacing </span>
                            <span style={{ fontSize: 12, color: C.textDim }}>{insight.paceAnalysis}</span>
                          </div>
                        )}
                        {insight.plannedVsActual && insight.plannedVsActual !== "null" && (
                          <div style={{ padding: "8px 0", borderTop: `1px solid ${C.border}` }}>
                            <span style={{ fontSize: 10, fontWeight: 700, color: C.cyan, textTransform: "uppercase", letterSpacing: 1 }}>Plan vs Actual </span>
                            <span style={{ fontSize: 12, color: C.textDim }}>{insight.plannedVsActual}</span>
                          </div>
                        )}
                        {/* Environment-Adjusted Pace */}
                        {insight.weather && selected.distance > 100 && selected.moving_time > 0 && (() => {
                          const w = insight.weather;
                          const rawPace = selected.moving_time / (selected.distance / 1609.34);
                          const wp = window.WeatherPacing.adjustForConditions({ tempF: w.tempF, humidityPct: w.humidity, windMph: w.windMph });
                          const adj = wp.totalSec > 0 && rawPace > 0 ? {
                            adjustedPace: Math.round(rawPace - wp.totalSec),
                            adjustPct: parseFloat((wp.totalSec / rawPace * 100).toFixed(1)),
                            conditions: wp.risk,
                          } : null;
                          if (!adj || adj.adjustPct < 0.5) return null;
                          return (
                            <div style={{ padding: "10px 0", borderTop: `1px solid ${C.border}` }}>
                              <div style={{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:6}}>
                                <span style={{ fontSize: 10, fontWeight: 700, color: C.cyan, textTransform: "uppercase", letterSpacing: 1 }}>
                                  Effort-Adjusted Pace
                                </span>
                                <span style={{fontSize:10,color:C.textMuted}}>
                                  {Math.round(w.tempF)}°F · {w.humidity}% · dp {Math.round(w.dewPointF)}°F · {adj.conditions}
                                </span>
                              </div>
                              <div style={{display:"flex",alignItems:"center",gap:12}}>
                                <div style={{fontSize:18,fontWeight:800,fontFamily:"'Space Grotesk',sans-serif",color:C.cyan}}>
                                  {fmt.pace(adj.adjustedPace).split(" ")[0]}
                                  <span style={{fontSize:11,fontWeight:400,color:C.textMuted}}>/mi</span>
                                </div>
                                <div style={{padding:"3px 8px",borderRadius:6,fontSize:11,fontWeight:700,
                                  background: adj.adjustPct > 3 ? C.amber+"20" : C.green+"20",
                                  color: adj.adjustPct > 3 ? C.amber : C.green}}>
                                  {adj.adjustPct}% faster in ideal conditions
                                </div>
                              </div>
                              <div style={{fontSize:11,color:C.textMuted,marginTop:4}}>
                                Your {fmt.pace(rawPace).split(" ")[0]}/mi effort equals {fmt.pace(adj.adjustedPace).split(" ")[0]}/mi in ideal conditions (50–55°F)
                              </div>
                            </div>
                          );
                        })()}
                        {insight.recoveryNote && (
                          <div style={{ padding: "8px 0", borderTop: `1px solid ${C.border}` }}>
                            <span style={{ fontSize: 10, fontWeight: 700, color: C.green, textTransform: "uppercase", letterSpacing: 1 }}>Recovery </span>
                            <span style={{ fontSize: 12, color: C.textDim }}>{insight.recoveryNote}</span>
                          </div>
                        )}
                        {insight.weekContext && (
                          <div style={{ padding: "8px 0", borderTop: `1px solid ${C.border}` }}>
                            <span style={{ fontSize: 10, fontWeight: 700, color: C.purple, textTransform: "uppercase", letterSpacing: 1 }}>Week Context </span>
                            <span style={{ fontSize: 12, color: C.textDim }}>{insight.weekContext}</span>
                          </div>
                        )}
                      </div>
                    </div>
                  );
                })()}

                {/* Delete button */}
                {isOwner && <div style={{ marginTop: 16, display: "flex", justifyContent: "flex-end" }}>
                  <button onClick={() => { if(confirm("Delete this activity permanently?")) deleteActivity(selected.id); }}
                    disabled={deleting === selected.id}
                    style={{ padding: "6px 14px", borderRadius: 6, border: `1px solid ${C.red}40`, cursor: "pointer",
                      background: "transparent", color: C.red, fontSize: 11, fontFamily: "'IBM Plex Mono',monospace" }}>
                    {deleting === selected.id ? "Deleting..." : "Delete Activity"}
                  </button>
                </div>}
              </Card></div>
            );
          })()}
        </div>
      );
    }

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