// running/src/settings.jsx — slice 17 of the module split.
//
// SETTINGS / CONNECTIONS: Strava sync + gear, Garmin userscript setup
// and workout push, Runalyze, HR-zone configuration (writes the
// persisted hrZones doc the athleteProfile and the server read),
// notification + account panels. Extracted verbatim from
// running/index.html. All service calls go through the firebase
// globals; zone building through window.HRZones consumers upstream.

const { C, Card, Btn } = window.SharedUI;
const useData = () => (window.__appUseData ? window.__appUseData() : null);
const { useState, useEffect, useMemo } = React;

    function Settings({ userId, hrZoneConfig, onHrZonesUpdated, activities, isOwner }) {
      const { healthEntries, athleteProfile, refreshNutrition, requestPlanRefresh } = useData();
      const [garminStatus, setGarminStatus] = useState(null);
      const [garminSyncKey, setGarminSyncKey] = useState(null);
      const [bookmarkletCopied, setBookmarkletCopied] = useState(false);
      const [stravaStatus, setStravaStatus] = useState(null);
      const [stravaLastSync, setStravaLastSync] = useState(null);
      const [stravaSyncMsg, setStravaSyncMsg] = useState(null);
      const [stravaStreamSettings, setStravaStreamSettings] = useState({ fetchStreams: false, streamsWindowDays: 60 });
      const [savingStravaStreams, setSavingStravaStreams] = useState(false);
      const [streamsSettingsSaved, setStreamsSettingsSaved] = useState(false);
      useEffect(() => {
        if (!userId) return;
        db.collection("users").doc(userId).collection("strava").doc("syncSettings")
          .get().then(doc => {
            if (doc.exists) {
              const d = doc.data();
              setStravaStreamSettings({
                fetchStreams: !!d.fetchStreams,
                streamsWindowDays: d.streamsWindowDays || 60,
              });
            }
          }).catch(() => {});
      }, [userId]);
      const runStravaSync = async () => {
        setSyncing("strava");
        setStravaSyncMsg(null);
        try {
          const syncStravaActivities = functions.httpsCallable("syncStravaActivities");
          const syncStravaGear = functions.httpsCallable("syncStravaGear");
          const [actResult] = await Promise.all([
            syncStravaActivities({ forceFullSync: false }),
            syncStravaGear({}).catch(e => console.warn("[sync] gear failed:", e.message)),
          ]);
          const count = actResult?.data?.count || 0;
          const enriched = actResult?.data?.streamsEnriched || 0;
          setStravaSyncMsg({
            type: "ok",
            text: `${count} activities synced${enriched ? ` · ${enriched} runs enriched with streams` : ""} · gear refreshed`,
          });
          setStravaLastSync({ toDate: () => new Date() });
          if (count > 0) setTimeout(() => window.location.reload(), 1500);
        } catch (e) {
          setStravaSyncMsg({ type: "err", text: "Sync failed: " + (e.message || "unknown") });
        }
        setSyncing(null);
      };
      const saveStravaStreamSettings = async () => {
        setSavingStravaStreams(true);
        setStreamsSettingsSaved(false);
        try {
          await db.collection("users").doc(userId).collection("strava").doc("syncSettings")
            .set({
              fetchStreams: !!stravaStreamSettings.fetchStreams,
              streamsWindowDays: Math.max(7, Math.min(180, Number(stravaStreamSettings.streamsWindowDays) || 60)),
              updatedAt: firebase.firestore.FieldValue.serverTimestamp(),
            }, { merge: true });
          setStreamsSettingsSaved(true);
          setTimeout(() => setStreamsSettingsSaved(false), 2500);
        } catch (e) {
          console.error("[strava-stream-settings] save failed", e);
        }
        setSavingStravaStreams(false);
      };
      const [dynamicsStatus, setDynamicsStatus] = useState(null);
      const [runalyzeToken, setRunalyzeToken] = useState("");
      const [runalyzeInspect, setRunalyzeInspect] = useState(null); // discovery result
      const [inspecting, setInspecting] = useState(false);
      const [syncing, setSyncing] = useState(null);
      const [error, setError] = useState(null);
      const [success, setSuccess] = useState(null);
      // Garmin OAuth (server-side, used for pushing workouts to the watch).
      // Independent of the bookmarklet sync above — that one rides the
      // user's browser cookies; this one uses email + password + MFA on the
      // server. Tokens stored at config/garmin_tokens; expire every ~30
      // days. UI below in the Garmin Connect card lets the user kick off
      // the MFA flow when they expire.
      const [garminOauthStep, setGarminOauthStep] = useState("idle"); // idle | sending | code-sent | verifying | done
      const [garminOauthCode, setGarminOauthCode] = useState("");
      const [garminOauthMsg, setGarminOauthMsg] = useState(null); // { type: "ok"|"err", text }
      const [garminClearing, setGarminClearing] = useState(false);

      const garminSendMFACode = async () => {
        setGarminOauthStep("sending"); setGarminOauthMsg(null);
        try {
          const r = await functions.httpsCallable("garminInitMFA")();
          setGarminOauthMsg({ type: "ok", text: r?.data?.message || "Code sent — check your email." });
          setGarminOauthStep("code-sent");
        } catch (e) {
          setGarminOauthMsg({ type: "err", text: e.message || "Failed to send code" });
          setGarminOauthStep("idle");
        }
      };
      const garminVerifyMFACode = async () => {
        const code = (garminOauthCode || "").trim();
        if (!code) { setGarminOauthMsg({ type: "err", text: "Enter the 6-digit code from your email" }); return; }
        setGarminOauthStep("verifying"); setGarminOauthMsg(null);
        try {
          await functions.httpsCallable("garminMFAAuth")({ mfaCode: code });
          setGarminOauthMsg({ type: "ok", text: "Connected ✓ — workout pushes will work now" });
          setGarminOauthStep("done");
          setGarminOauthCode("");
        } catch (e) {
          setGarminOauthMsg({ type: "err", text: e.message || "Verification failed" });
          setGarminOauthStep("code-sent"); // stay on the code input so they can retry
        }
      };
      // Clear our local 120-min rate-limit cache. Garmin's actual backend
      // limit is often shorter (15-60 min); this button lets the user
      // retry sooner at the risk of re-triggering the 429 and restarting
      // our cache clock if Garmin hasn't actually cooled off yet.
      const garminClearRateLimit = async () => {
        setGarminClearing(true);
        try {
          await functions.httpsCallable("garminClearRateLimit")();
          setGarminOauthMsg({ type: "ok", text: "Rate-limit cleared. Send a fresh MFA code and retry." });
          setGarminOauthStep("idle");
          setGarminOauthCode("");
        } catch (e) {
          setGarminOauthMsg({ type: "err", text: "Clear failed: " + (e.message || e) });
        }
        setGarminClearing(false);
      };
      const [mergePairs, setMergePairs] = useState(null); // null=not run, []|[...]=preview results
      const [merging, setMerging] = useState(false);

      // Garmin scraper cookie status
      const [scraperStatus, setScraperStatus] = useState(null); // {updatedAt, expiresApprox}
      const [cookiePaste, setCookiePaste] = useState(null);
      const [savingCookies, setSavingCookies] = useState(false);
      useEffect(() => {
        db.doc("config/garmin_scraper_cookies").get().then(doc => {
          if (doc.exists) {
            const d = doc.data();
            setScraperStatus({
              updatedAt: d.updatedAt?.toDate?.() || null,
              expiresApprox: d.expiresApprox?.toDate?.() || null,
              hasCookies: !!d.cookies,
            });
          }
        }).catch(() => {});
      }, [userId]);

      // MFP sync state
      const [mfpStatus, setMfpStatus] = useState(null);
      useEffect(() => {
        if (!userId) return;
        db.collection("users").doc(userId).collection("settings").doc("mfp").get().then(doc => {
          if (doc.exists) {
            const d = doc.data();
            setMfpStatus({ connected: !!d.username, username: d.username || "", lastSync: d.lastSync?.toDate?.() || null });
          } else {
            setMfpStatus({ connected: false });
          }
        });
      }, [userId]);

      // ── Telegram coach-bot state ──
      // chatId: null until paired. linkCode/linkCodeIssuedAt are
      // transient — present while waiting for the user to send /start
      // to the bot, null once paired or expired.
      const [tgLink, setTgLink] = useState(null);
      const [tgGenerating, setTgGenerating] = useState(false);
      const [tgMsg, setTgMsg] = useState(null);
      useEffect(() => {
        if (!userId) return;
        const unsub = db.collection("users").doc(userId).collection("settings").doc("telegram")
          .onSnapshot(d => setTgLink(d.exists ? d.data() : null));
        return unsub;
      }, [userId]);
      const tgGenerateCode = async () => {
        setTgGenerating(true); setTgMsg(null);
        try {
          const r = await functions.httpsCallable("generateTelegramLinkCode")();
          setTgMsg({ type: "ok", text: `Code: ${r.data.code} — send /start ${r.data.code} to the bot within ${r.data.expiresInMinutes} min.` });
        } catch (e) {
          setTgMsg({ type: "err", text: e.message });
        }
        setTgGenerating(false);
      };
      const tgUnlink = async () => {
        if (!confirm("Unlink Telegram? You'll stop receiving coach pushes and won't be able to chat with the bot until you re-pair.")) return;
        try {
          await functions.httpsCallable("unlinkTelegram")();
          setTgMsg({ type: "ok", text: "Unlinked." });
        } catch (e) {
          setTgMsg({ type: "err", text: e.message });
        }
      };
      const tgSetNotif = async (key, value) => {
        try {
          const next = { ...(tgLink?.notifications || {}), [key]: value };
          await db.collection("users").doc(userId).collection("settings").doc("telegram")
            .set({ notifications: next }, { merge: true });
        } catch (e) {
          setTgMsg({ type: "err", text: e.message });
        }
      };

      const syncMfp = async (days = 7) => {
        setSyncing("mfp");
        setError(null);
        try {
          const fn = functions.httpsCallable("syncMfpServer");
          const result = await fn({ daysBack: days });
          setMfpStatus(prev => ({ ...prev, lastSync: new Date() }));
          const r = result.data;
          if (r.saved > 0) {
            setSuccess(`MFP synced: ${r.saved} days of nutrition data`);
            refreshNutrition(); // Refresh DataProvider so UI shows new values immediately
          } else {
            setError(`MFP sync returned 0 days for user "${r.username}". MFP may be blocking server requests — try the paste import on the Nutrition tab instead.${r.errors?.length ? " Errors: " + r.errors.map(e => e.date + ": " + e.error).join(", ") : ""}`);
          }
        } catch (err) {
          setError("MFP sync failed: " + (err.message || err.details));
        } finally { setSyncing(null); }
      };

      // MFP popup bookmarklet sync
      const [mfpBookmarkletToken, setMfpBookmarkletToken] = useState(null);
      const [mfpSyncOverlay, setMfpSyncOverlay] = useState(false);
      const [mfpSyncProgress, setMfpSyncProgress] = useState("");
      const [mfpScriptCopied, setMfpScriptCopied] = useState(false);

      // Load MFP bookmarklet token
      useEffect(() => {
        if (!userId) return;
        db.collection("users").doc(userId).collection("settings").doc("mfp").get().then(async doc => {
          if (doc.exists && doc.data().bookmarkletToken) {
            setMfpBookmarkletToken(doc.data().bookmarkletToken);
          } else if (doc.exists && doc.data().username) {
            // Generate token if missing
            const token = Array.from(crypto.getRandomValues(new Uint8Array(24)), b => b.toString(16).padStart(2,"0")).join("");
            await db.collection("users").doc(userId).collection("settings").doc("mfp").set({ bookmarkletToken: token }, { merge: true });
            setMfpBookmarkletToken(token);
          }
        }).catch(() => {});
      }, [userId]);

      // Listen for postMessage from MFP scraper
      useEffect(() => {
        const handler = (event) => {
          if (!event.data || event.data.type !== "mfp-sync-progress") return;
          const { status, day, total, date, error: err } = event.data;
          if (status === "scraping") {
            setMfpSyncProgress(`Syncing day ${day} of ${total}... (${date})`);
          } else if (status === "posting") {
            setMfpSyncProgress(`Saving ${date} to database...`);
          } else if (status === "done") {
            setMfpSyncProgress("");
            setMfpSyncOverlay(false);
            setMfpScriptCopied(false);
            setMfpStatus(prev => ({ ...prev, lastSync: new Date() }));
            setSuccess(`MFP synced: ${event.data.saved || total} days of nutrition data`);
          } else if (status === "error") {
            setMfpSyncProgress("");
            setError(`MFP sync error: ${err || "unknown"}`);
          }
        };
        window.addEventListener("message", handler);
        return () => window.removeEventListener("message", handler);
      }, []);

      const getMfpSyncScript = () => {
        if (!mfpBookmarkletToken || !mfpStatus?.username) return "";
        const username = mfpStatus.username;
        const token = mfpBookmarkletToken;
        const endpoint = "https://us-central1-cooke-financial-hub-942db.cloudfunctions.net/mfpBookmarkletSync";
        return `(async function(){
  var opener=window.opener;
  var post=function(d){try{opener.postMessage(d,"*")}catch(e){}};
  var username="${username}";
  var token="${token}";
  var endpoint="${endpoint}";
  var uid="${userId}";
  var days=7;
  var results=[];
  function parseDay(doc){
    var items=[],meals={},totals={calories:0,protein:0,carbs:0,fat:0};
    var meal="";
    var g=function(x){return parseFloat((x.textContent||"").replace(/,/g,"").replace(/[^0-9.\\-]/g,""))||0};
    var rows=doc.querySelectorAll("#diary-table tr, table.table0 tr");
    if(rows.length===0)rows=doc.querySelectorAll("table tr");
    console.log("[MFP] Found "+rows.length+" rows");
    rows.forEach(function(r){
      var txt=(r.querySelector("td:first-child")||{}).textContent||"";
      var txtLower=txt.toLowerCase();
      if(r.classList.contains("meal_header")){meal=txt.trim()}
      else if(txt.trim()==="Totals"&&!txtLower.includes("goal")&&!txtLower.includes("remaining")){
        var c=r.querySelectorAll("td");
        var nums=[];c.forEach(function(cell){var v=parseFloat(cell.textContent.replace(/,/g,"").replace(/[^0-9.\\-]/g,""));if(!isNaN(v)&&cell.textContent.match(/\\d/))nums.push(v)});
        if(nums.length>=4){totals={calories:nums[0],carbs:nums[1],fat:nums[2],protein:nums[3]};console.log("[MFP] Totals:",JSON.stringify(totals))}
      }else if(txtLower.includes("goal")||txtLower.includes("remaining")){
        console.log("[MFP] Skipping goal/remaining row:",txt.trim());
      }else{
        var c=r.querySelectorAll("td");
        if(c.length>=5){var link=c[0].querySelector("a");var name=link?(link.textContent||"").trim():"";
          if(name&&name!=="Add Food"&&name!=="Quick Tools"){
            var it={meal:meal,name:name,calories:g(c[1]),carbs:g(c[2]),fat:g(c[3]),protein:g(c[4])};
            items.push(it);if(!meals[meal])meals[meal]=[];meals[meal].push(it)}}
      }
    });
    if(totals.calories===0&&items.length===0){
      var m=doc.body.innerText.match(/Totals\\s+([\\d,]+)\\s+([\\d,]+)\\s+([\\d,]+)\\s+([\\d,]+)/);
      if(m){totals={calories:parseInt(m[1].replace(/,/g,"")),carbs:parseInt(m[2].replace(/,/g,"")),fat:parseInt(m[3].replace(/,/g,"")),protein:parseInt(m[4].replace(/,/g,""))};console.log("[MFP] Regex totals:",JSON.stringify(totals))}
    }
    if(items.length>0){var ic=items.reduce(function(s,it){return s+it.calories},0);
      if(ic>0&&Math.abs(ic-totals.calories)/ic>0.15){totals={calories:ic,protein:items.reduce(function(s,it){return s+it.protein},0),carbs:items.reduce(function(s,it){return s+it.carbs},0),fat:items.reduce(function(s,it){return s+it.fat},0)};console.log("[MFP] Using item-sum totals:",JSON.stringify(totals))}}
    console.log("[MFP] Result: "+items.length+" items, "+totals.calories+" cal");
    return {items:items,meals:meals,totals:totals};
  }
  for(var i=0;i<days;i++){
    var d=new Date();d.setDate(d.getDate()-i);
    var ds=d.toISOString().split("T")[0];
    console.log("[MFP] Day "+(i+1)+"/"+days+": "+ds);
    post({type:"mfp-sync-progress",status:"scraping",day:i+1,total:days,date:ds});
    var parsed;
    if(i===0){
      parsed=parseDay(document);
    }else{
      try{
        var resp=await fetch("/food/diary/"+username+"?date="+ds,{credentials:"include"});
        var html=await resp.text();
        var parser=new DOMParser();
        var doc=parser.parseFromString(html,"text/html");
        parsed=parseDay(doc);
      }catch(e){console.error("[MFP] Fetch error:",e);results.push({date:ds,success:false,error:e.message});continue}
    }
    var items=parsed.items,meals=parsed.meals,totals=parsed.totals;
    if(totals.calories>0){
      post({type:"mfp-sync-progress",status:"posting",day:i+1,total:days,date:ds});
      try{
        var payload={token:token,uid:uid,date:ds,meals:meals,totals:totals,items:items.length>0?items:[{meal:"Total",name:"Daily Total",calories:totals.calories,carbs:totals.carbs,fat:totals.fat,protein:totals.protein}]};
        var r2=await fetch(endpoint,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(payload)});
        var j=await r2.json();
        console.log("[MFP] Saved "+ds+": "+totals.calories+" cal",j);
        results.push({date:ds,success:j.success,calories:totals.calories});
      }catch(e){console.error("[MFP] Post error:",e);results.push({date:ds,success:false,error:e.message})}
    }else{console.log("[MFP] No data for "+ds);results.push({date:ds,success:true,skipped:true})}
  }
  var saved=results.filter(function(r){return r.success&&r.calories>0}).length;
  post({type:"mfp-sync-progress",status:"done",saved:saved,total:days,results:results});
  console.log("[MFP] Done! "+saved+" days",results);
  alert("Synced "+saved+" days! You can close this tab.");
})();`;
      };

      const startMfpPopupSync = () => {
        if (!mfpStatus?.username) return;
        window.open(`https://www.myfitnesspal.com/food/diary/${mfpStatus.username}`, "_blank");
      };

      // Garmin popup bookmarklet sync
      const [garminSyncOverlay, setGarminSyncOverlay] = useState(false);
      const [garminSyncProgress, setGarminSyncProgress] = useState("");
      const [garminScriptCopied, setGarminScriptCopied] = useState(false);
      const [lastGarminSyncResult, setLastGarminSyncResult] = useState(null);
      const [healthDiag, setHealthDiag] = useState(null);
      const [healthDiagLoading, setHealthDiagLoading] = useState(false);
      const [gcSyncResult, setGcSyncResult] = useState(null);
      const [gcSyncing, setGcSyncing] = useState(false);
      const [scraperDebug, setScraperDebug] = useState(null);
      const [scraperDebugOpen, setScraperDebugOpen] = useState(false);
      const [scraperDebugSearch, setScraperDebugSearch] = useState("");
      const [scraperDebugKey, setScraperDebugKey] = useState(null);
      const [backfillOpen, setBackfillOpen] = useState(false);
      const [backfillMonths, setBackfillMonths] = useState(6);
      const [backfillProgress, setBackfillProgress] = useState(null);
      const [backfilling, setBackfilling] = useState(false);
      const [bbBackfill, setBbBackfill] = useState(null); // null | "running" | result string

      // Drives the server-side backfillGarminHistory callable (which uses the
      // stored Garmin OAuth session, not the 14-day-capped bookmarklet) to
      // heal deep Body Battery / health gaps. Chunked + resumable: the server
      // processes ≤60 days per call, so loop with resume:true until complete.
      const runBodyBatteryBackfill = async () => {
        setBbBackfill("running");
        const call = functions.httpsCallable("backfillGarminHistory", { timeout: 540000 });
        // The server returns a *cumulative* daysWithData across all chunks
        // (it folds prior progress in), so take the latest — don't sum.
        let totalDays = 0;
        try {
          let res = await call({ monthsBack: 6, includeActivities: false });
          for (let i = 0; i < 12; i++) {
            const d = res.data || {};
            if (Number.isFinite(d.daysWithData)) totalDays = d.daysWithData;
            if (d.rateLimited) {
              setBbBackfill("Garmin rate-limited — progress saved. Click again in a few minutes to resume where it left off.");
              return;
            }
            if (d.complete) {
              setBbBackfill(`Backfilled ${totalDays} days of Body Battery & health${d.errors ? ` (${d.errors} skipped)` : ""}. Reload to view.`);
              return;
            }
            setBbBackfill(`running:~${d.daysRemaining || 0} days left`);
            res = await call({ resume: true, includeActivities: false });
          }
          setBbBackfill(`Backfilled ${totalDays} days so far — click again to continue (a long history takes several passes).`);
        } catch (e) {
          const msg = e?.message || String(e);
          if (/session|precondition/i.test(msg)) {
            // The server OAuth path is the fragile one (needs MFA re-auth,
            // Garmin rate-limits it). For a deep gap the ZIP import is the
            // reliable route — auto-open that panel so it's not buried.
            setBackfillOpen(true);
            setBbBackfill("Garmin's server session is unavailable (it needs MFA re-auth and Garmin rate-limits it). For a gap this deep, use Historical Backfill below — opened for you — to import a Garmin \"Export Your Data\" ZIP. That's the reliable route.");
          } else {
            setBbBackfill(`Backfill failed: ${msg}`);
          }
        }
      };

      // Listen for postMessage from Garmin scraper
      useEffect(() => {
        const handler = (event) => {
          if (!event.data || event.data.type !== "garmin-sync-progress") return;
          const { status, day, total, date, error: err, healthDays } = event.data;
          if (status === "fetching-profile") {
            setGarminSyncProgress("Fetching Garmin profile...");
          } else if (status === "fetching") {
            setGarminSyncProgress(`Fetching day ${day} of ${total}... (${date})`);
          } else if (status === "posting") {
            setGarminSyncProgress("Sending data to server...");
          } else if (status === "done") {
            setGarminSyncProgress("");
            setGarminSyncOverlay(false);
            setGarminScriptCopied(false);
            setScraperStatus(prev => ({ ...prev, hasCookies: true, updatedAt: new Date() }));
            setLastGarminSyncResult({ healthDays, bodyCompSummary: event.data.bodyCompSummary, activitiesEnriched: event.data.activitiesEnriched, perfStats: event.data.perfStats, daysSummary: event.data.daysSummary, syncedAt: new Date() });
            setSuccess(`Garmin synced: ${healthDays || total || 7} days of health data`);
          } else if (status === "error") {
            setGarminSyncProgress("");
            setError(`Garmin sync error: ${err || "unknown"}`);
          }
        };
        window.addEventListener("message", handler);
        return () => window.removeEventListener("message", handler);
      }, []);

      const getGarminSyncScript = () => {
        if (!garminSyncKey) return "";
        const key = garminSyncKey;
        const endpoint = "https://us-central1-cooke-financial-hub-942db.cloudfunctions.net/garminImport";
        return `(async function(){
  var opener=window.opener;
  var post=function(d){try{opener.postMessage(d,"*")}catch(e){}};
  var uid="${userId}";
  var key="${key}";
  var endpoint="${endpoint}";
  var days=30;
  /* Progress overlay */
  var ov=document.createElement("div");
  ov.id="garmin-sync-overlay";
  ov.style.cssText="position:fixed;top:16px;right:16px;z-index:99999;background:#1a7a3a;color:#fff;padding:16px 24px;border-radius:12px;font-family:sans-serif;font-size:14px;box-shadow:0 4px 20px rgba(0,0,0,.3);min-width:280px";
  ov.innerHTML="<div style='font-weight:700;margin-bottom:8px'>Garmin Sync</div><div id='gs-status'>Starting...</div>";
  document.body.appendChild(ov);
  var st=document.getElementById("gs-status");
  function status(t){st.textContent=t}
  try{
    /* Get username */
    post({type:"garmin-sync-progress",status:"fetching-profile"});
    status("Getting profile...");
    var pResp=await fetch("/proxy/userprofile-service/userprofile/personal-information",{credentials:"include"});
    if(!pResp.ok)throw new Error("Not logged in ("+pResp.status+"). Log into Garmin Connect first.");
    var profile=await pResp.json();
    var username=profile.displayName||profile.userName||"";
    /* Fetch 7 days of health data */
    var dates={};
    for(var i=0;i<days;i++){
      var d=new Date();d.setDate(d.getDate()-i);
      var ds=d.toISOString().split("T")[0];
      post({type:"garmin-sync-progress",status:"fetching",day:i+1,total:days,date:ds});
      status("Fetching "+ds+" ("+(i+1)+"/"+days+")...");
      var dayData={};
      try{var r=await fetch("/proxy/wellness-service/wellness/bodyBattery/reports/daily?startDate="+ds+"&endDate="+ds,{credentials:"include"});if(r.ok)dayData.bodyBattery=await r.json()}catch(e){}
      try{var r=await fetch("/proxy/hrv-service/hrv/"+ds,{credentials:"include"});if(r.ok)dayData.hrv=await r.json()}catch(e){}
      try{var r=await fetch("/proxy/wellness-service/wellness/dailyStress/"+ds,{credentials:"include"});if(r.ok)dayData.stress=await r.json()}catch(e){}
      try{var r=await fetch("/proxy/wellness-service/wellness/dailySleepData/"+username+"?date="+ds+"&nonSleepBufferMinutes=60",{credentials:"include"});if(r.ok)dayData.sleep=await r.json()}catch(e){}
      try{var r=await fetch("/proxy/wellness-service/wellness/daily/spo2/"+ds,{credentials:"include"});if(r.ok)dayData.spo2=await r.json()}catch(e){}
      try{var r=await fetch("/proxy/wellness-service/wellness/daily/respiration/"+ds,{credentials:"include"});if(r.ok)dayData.respiration=await r.json()}catch(e){}
      try{var r=await fetch("/proxy/wellness-service/wellness/dailyHeartRate/"+username+"?date="+ds,{credentials:"include"});if(r.ok)dayData.heartRate=await r.json()}catch(e){}
      try{var r=await fetch("/proxy/training-readiness-service/trainingreadiness/"+ds,{credentials:"include"});if(r.ok)dayData.trainingReadiness=await r.json()}catch(e){}
      try{var r=await fetch("/proxy/wellness-service/wellness/dailySummary/"+username+"?calendarDate="+ds,{credentials:"include"});if(r.ok)dayData.dailySummary=await r.json()}catch(e){}
      // Daily VO2max (Firstbeat). Garmin's web UI shows this with 1dp on every
      // day; the metrics-service endpoint mirrors what the watch surfaces.
      // Try a couple of URL shapes — Garmin has migrated this around.
      try{
        var mmUrls=[
          "/proxy/metrics-service/metrics/maxmet/daily/"+ds,
          "/proxy/metrics-service/metrics/maxmet/"+ds
        ];
        for(var mu=0;mu<mmUrls.length;mu++){
          try{var rr=await fetch(mmUrls[mu],{credentials:"include"});if(rr.ok){dayData.maxMet=await rr.json();break}}catch(e){}
        }
      }catch(e){}
      dates[ds]=dayData;
    }
    /* Fetch recent running activities (last 30 days) with full detail */
    var activities=[];
    try{
      status("Fetching recent activities...");
      var actUrl="/proxy/activitylist-service/activities/search/activities?start=0&limit=40&activityType=running&_="+Date.now();
      var actResp=await fetch(actUrl,{credentials:"include"});
      if(actResp.ok){
        var actList=await actResp.json();
        var actArr=Array.isArray(actList)?actList:(actList.activityList||actList.activities||[]);
        var cutoffMs=Date.now()-30*86400000;
        var recentActs=actArr.filter(function(a){return new Date(a.startTimeGMT||a.startTimeLocal).getTime()>=cutoffMs;});
        for(var j=0;j<Math.min(recentActs.length,25);j++){
          var actId=recentActs[j].activityId;
          status("Fetching activity "+(j+1)+"/"+Math.min(recentActs.length,15)+"...");
          var actObj;
          try{
            var detResp=await fetch("/proxy/activity-service/activity/"+actId,{credentials:"include"});
            actObj=detResp.ok?await detResp.json():recentActs[j];
          }catch(e){actObj=recentActs[j];}
          /* Per-second running-dynamics metrics — feeds the 0.25-mile block charts on the Form tab */
          try{
            var mResp=await fetch("/proxy/activity-service/activity/"+actId+"/details?maxChartSize=300&maxPolylineSize=0",{credentials:"include"});
            if(mResp.ok){var md=await mResp.json();actObj.crDetailMetrics={metricDescriptors:md.metricDescriptors||null,activityDetailMetrics:md.activityDetailMetrics||null};}
          }catch(e){}
          activities.push(actObj);
        }
      }
    }catch(e){}
    /* Fetch weight/body comp for the full range */
    var weight=null;
    try{
      var startD=new Date();startD.setDate(startD.getDate()-(days-1));
      var startDs=startD.toISOString().split("T")[0];
      var endDs=new Date().toISOString().split("T")[0];
      status("Fetching body composition...");
      var r=await fetch("/proxy/weight-service/weight/dateRange?startDate="+startDs+"&endDate="+endDs,{credentials:"include"});
      if(r.ok)weight=await r.json();
    }catch(e){}
    /* Capture cookies */
    var cookies=document.cookie;
    /* POST to cloud function */
    post({type:"garmin-sync-progress",status:"posting"});
    status("Saving to database...");
    var resp=await fetch(endpoint,{method:"POST",headers:{"Content-Type":"application/json"},
      body:JSON.stringify({uid:uid,key:key,data:{dates:dates,weight:weight,activities:activities,cookies:cookies}})});
    var result=await resp.json();
    if(!result.success)throw new Error(result.error||"Server error");
    post({type:"garmin-sync-progress",status:"done",healthDays:result.healthDays,bodyCompSummary:result.bodyCompSummary,activitiesEnriched:result.activitiesEnriched,perfStats:result.perfStats,daysSummary:result.daysSummary});
    status("Done! "+result.healthDays+" days synced.");
    ov.style.background="#155724";
    document.title="Garmin sync complete! You can close this tab.";
    alert("Garmin synced "+result.healthDays+" days! You can close this tab.");
  }catch(e){
    post({type:"garmin-sync-progress",status:"error",error:e.message});
    status("Error: "+e.message);
    ov.style.background="#8b0000";
  }
})();`;
      };

      const startGarminPopupSync = () => {
        window.open("https://connect.garmin.com/modern/", "_blank");
      };

      // Daily summary email settings
      const [summaryEmail, setSummaryEmail] = useState("");
      const [summaryEnabled, setSummaryEnabled] = useState(false);
      const [summaryTime, setSummaryTime] = useState("06:30");
      const [summaryLoaded, setSummaryLoaded] = useState(false);
      const [sendingTest, setSendingTest] = useState(false);

      // Load email settings
      useEffect(() => {
        if (!userId) return;
        db.collection("users").doc(userId).collection("settings").doc("notifications")
          .get().then(doc => {
            if (doc.exists) {
              const d = doc.data();
              if (d.dailySummaryEmail) setSummaryEmail(d.dailySummaryEmail);
              if (d.dailySummaryEnabled != null) setSummaryEnabled(d.dailySummaryEnabled);
              if (d.dailySummaryTime) setSummaryTime(d.dailySummaryTime);
            }
            setSummaryLoaded(true);
          });
      }, [userId]);

      // Save email settings when changed (after initial load)
      useEffect(() => {
        if (!userId || !summaryLoaded) return;
        db.collection("users").doc(userId).collection("settings").doc("notifications")
          .set({
            dailySummaryEmail: summaryEmail,
            dailySummaryEnabled: summaryEnabled,
            dailySummaryTime: summaryTime,
            updatedAt: firebase.firestore.FieldValue.serverTimestamp(),
          }, { merge: true });
        // Also update the settings doc used by the scheduled function
        db.collection("users").doc(userId).collection("settings").doc("dailySummary")
          .set({
            dailySummaryEmail: summaryEnabled ? summaryEmail : "",
            dailySummaryEnabled: summaryEnabled,
            updatedAt: firebase.firestore.FieldValue.serverTimestamp(),
          }, { merge: true });
      }, [summaryEmail, summaryEnabled, summaryTime, userId, summaryLoaded]);

      const sendTestEmail = async () => {
        if (!summaryEmail) { setError("Enter an email address first"); return; }
        setSendingTest(true);
        setError(null);
        try {
          const fn = functions.httpsCallable("sendDailySummaryNow");
          const result = await fn({ email: summaryEmail });
          setSuccess(result.data.message || "Daily summary sent!");
        } catch (err) {
          setError("Failed to send test email: " + (err.message || "Unknown error"));
        }
        setSendingTest(false);
      };

      const sendWeeklyEmail = async () => {
        if (!summaryEmail) { setError("Enter an email address first"); return; }
        setSendingTest(true);
        setError(null);
        try {
          const fn = functions.httpsCallable("sendWeeklySummaryNow");
          await fn({ email: summaryEmail });
          setSuccess("Weekly summary sent!");
        } catch (err) {
          setError("Failed to send weekly email: " + (err.message || "Unknown error"));
        }
        setSendingTest(false);
      };

      // Training methodology state
      const [trainingMethodology, setTrainingMethodology] = useState("polarized");
      const [methodologyLoaded, setMethodologyLoaded] = useState(false);

      useEffect(() => {
        if (!userId) return;
        db.collection("users").doc(userId).collection("settings").doc("trainingMethodology")
          .get().then(doc => {
            if (doc.exists && doc.data().methodology) setTrainingMethodology(doc.data().methodology);
            setMethodologyLoaded(true);
          });
      }, [userId]);

      const saveMethodology = (method) => {
        setTrainingMethodology(method);
        if (userId) {
          db.collection("users").doc(userId).collection("settings").doc("trainingMethodology")
            .set({ methodology: method, updatedAt: firebase.firestore.FieldValue.serverTimestamp() }, { merge: true });
        }
      };

      // HR Zone Advisor state
      const [hrAge, setHrAge] = useState("");
      const [hrGender, setHrGender] = useState("male");
      const [hrResting, setHrResting] = useState("");
      const [hrMaxObs, setHrMaxObs] = useState("");
      const [hrLTHR, setHrLTHR] = useState("");
      const [hrPreference, setHrPreference] = useState("");
      const [hrAnalyzing, setHrAnalyzing] = useState(false);
      const [hrResult, setHrResult] = useState(hrZoneConfig);
      const [hrShowInstructions, setHrShowInstructions] = useState(null); // "strava" | "garmin" | null
      const [reclassifying, setReclassifying] = useState(false);
      const [reclassifyResult, setReclassifyResult] = useState(null);

      // Pre-fill from existing analysis
      useEffect(() => {
        if (hrZoneConfig) {
          setHrResult(hrZoneConfig);
          if (hrZoneConfig.athleteInputs) {
            const inp = hrZoneConfig.athleteInputs;
            if (inp.age) setHrAge(String(inp.age));
            if (inp.restingHR) setHrResting(String(inp.restingHR));
            if (inp.maxHRObserved) setHrMaxObs(String(inp.maxHRObserved));
            if (inp.lthr) setHrLTHR(String(inp.lthr));
          }
        }
      }, [hrZoneConfig]);

      const runHRAnalysis = async () => {
        setHrAnalyzing(true);
        setError(null);
        try {
          const fn = functions.httpsCallable("analyzeHRZones");
          const typedMaxHR = hrMaxObs ? parseInt(hrMaxObs) : null;
          const result = await fn({
            age: hrAge ? parseInt(hrAge) : null,
            gender: hrGender,
            restingHR: hrResting ? parseInt(hrResting) : null,
            maxHRObserved: typedMaxHR,
            lthr: hrLTHR ? parseInt(hrLTHR) : null,
            preferences: hrPreference || null,
          });
          // Belt-and-suspenders pin: the server includes maxHRManual:true
          // on the response when the athlete typed a Max HR, but stamp
          // it client-side too so the in-memory state is guaranteed to
          // carry the flag before athleteProfile re-renders. Without
          // this, a glitchy observed 203 bpm would override the typed
          // 186 the moment athleteProfile recomputes.
          const analysis = { ...result.data.analysis };
          if (typedMaxHR && typedMaxHR > 0) analysis.maxHRManual = true;
          setHrResult(analysis);
          if (onHrZonesUpdated) onHrZonesUpdated(analysis);
          setSuccess("HR zones analyzed and saved!");
        } catch (err) {
          setError("HR zone analysis failed: " + (err.message || "Unknown error"));
        }
        setHrAnalyzing(false);
      };

      // Check connection status
      useEffect(() => {
        if (!userId) return;
        // Strava
        db.collection("users").doc(userId).collection("strava").doc("auth")
          .get().then(doc => {
            setStravaStatus(doc.exists && doc.data().accessToken ? { athleteName: doc.data().athleteName } : false);
            const last = doc.exists ? (doc.data().lastSync || doc.data().last_sync || null) : null;
            if (last) setStravaLastSync(last);
          });
        // Garmin Health API
        db.collection("users").doc(userId).collection("garmin").doc("auth")
          .get().then(doc => {
            setGarminStatus(doc.exists && doc.data().accessToken ? { connected: true } : false);
          });
        // (Removed during slice 17: two status-check blocks here called
        // setGarminConnectStatus / setGarminCooldown — state setters that
        // were never declared anywhere. They threw an unhandled
        // ReferenceError inside their Firestore .then() on every Settings
        // mount since the day they were written, and nothing reads the
        // status they tried to set — the UI that consumed it is long gone.
        // Found by the slice unbound-identifier scan.)
        // Load or generate Garmin bookmarklet sync key
        db.collection("users").doc(userId).collection("settings").doc("garminSync").get().then(async doc => {
          if (doc.exists && doc.data().key) {
            setGarminSyncKey(doc.data().key);
          } else {
            const key = Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2);
            await db.collection("users").doc(userId).collection("settings").doc("garminSync").set({ key, createdAt: firebase.firestore.FieldValue.serverTimestamp() });
            setGarminSyncKey(key);
          }
        }).catch(() => {});
        // Runalyze
        db.collection("users").doc(userId).collection("settings").doc("runalyze")
          .get().then(doc => {
            if (doc.exists && doc.data().apiToken) {
              setRunalyzeToken(doc.data().apiToken);
              const ls = doc.data().lastSync;
              setDynamicsStatus({ connected: true, lastSync: (ls && ls.toDate ? ls.toDate() : ls) || null, synced: doc.data().totalSynced || 0 });
            } else {
              setDynamicsStatus(false);
            }
          }).catch(() => setDynamicsStatus(false));
      }, [userId]);

      // Handle Garmin callback
      useEffect(() => {
        const params = new URLSearchParams(window.location.search);
        if (params.get("garmin") === "connected") {
          setGarminStatus({ connected: true });
          setSuccess("Garmin connected! Health data will sync automatically.");
          window.history.replaceState({}, "", window.location.pathname);
        }
      }, []);

      const connectGarmin = async () => {
        setSyncing("garmin");
        setError(null);
        try {
          const fn = functions.httpsCallable("garminOAuthRequestToken");
          const result = await fn({ callbackUrl: window.location.origin + "/auth/garmin/callback" });
          window.location.href = result.data.authUrl;
        } catch (err) {
          setError("Garmin connection failed: " + (err.message || "Unknown error") + ". You may need Garmin Health API developer access.");
          setSyncing(null);
        }
      };

      const saveRunalyzeToken = async () => {
        if (!runalyzeToken.trim()) { setError("Enter your Runalyze Personal API token"); return; }
        setError(null);
        try {
          await db.collection("users").doc(userId).collection("settings").doc("runalyze")
            .set({ apiToken: runalyzeToken.trim(), savedAt: firebase.firestore.FieldValue.serverTimestamp() }, { merge: true });
          setDynamicsStatus(prev => ({ ...(prev || {}), connected: true, lastSync: prev?.lastSync || null, synced: prev?.synced || 0 }));
          setSuccess("Runalyze API token saved.");
        } catch (err) { setError("Failed to save token: " + err.message); }
      };

      const syncRunalyzeAPI = async (fullSync = false) => {
        setSyncing("runalyzeAPI");
        setError(null);
        try {
          const fn = functions.httpsCallable("syncRunalyze");
          const result = await fn({ fullSync });
          setDynamicsStatus(prev => ({ ...prev, connected: true, lastSync: new Date(), synced: (prev?.synced || 0) + (result.data.merged || 0) + (result.data.created || 0) }));
          setSuccess(`Runalyze: ${result.data.fetched} fetched · ${result.data.merged} enriched · ${result.data.created} new · ${result.data.shoes || 0} shoes · ${result.data.skipped} skipped`);
        } catch (err) {
          setError("Runalyze sync failed: " + err.message);
        }
        setSyncing(null);
      };

      // Read-only discovery: shows the full Runalyze data surface + volume so
      // we can decide what the Garmin scrape can be retired for.
      const inspectRunalyze = async () => {
        setInspecting(true);
        setError(null);
        try {
          const fn = functions.httpsCallable("runalyzeDiscover");
          const result = await fn({});
          setRunalyzeInspect(result.data);
        } catch (err) {
          setError("Runalyze inspect failed: " + (err.message || err));
          setRunalyzeInspect(null);
        }
        setInspecting(false);
      };

      const syncGarminDynamics = async (fullSync = false) => {
        setSyncing("dynamics");
        setError(null);
        try {
          const fn = functions.httpsCallable("syncGarminDynamics");
          const result = await fn({ fullSync, days: fullSync ? 365 : 30 });
          setDynamicsStatus(prev => ({ ...prev, connected: true, lastSync: new Date(), synced: (prev?.synced || 0) + (result.data.merged || 0) }));
          setSuccess(`Running dynamics: ${result.data.fetched} Garmin activities scanned, ${result.data.merged} enriched with dynamics, ${result.data.skipped} skipped${result.data.errors ? `, ${result.data.errors} errors` : ""}`);
        } catch (err) {
          setError("Running dynamics sync failed: " + err.message);
        }
        setSyncing(null);
      };

      const ConnectionCard = ({ icon, name, description, status, onConnect, onSync, syncingThis, color }) => (
        <Card style={{ marginBottom: 16 }}>
          <div style={{ display: "flex", alignItems: "center", gap: 16 }}>
            <div style={{ fontSize: 32, width: 48, textAlign: "center" }}>{icon}</div>
            <div style={{ flex: 1 }}>
              <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
                <span style={{ fontSize: 16, fontWeight: 700, color: C.text }}>{name}</span>
                {status && status !== false && (
                  <span style={{ fontSize: 10, padding: "2px 8px", borderRadius: 12, background: `${C.green}20`, color: C.green, fontWeight: 600 }}>Connected</span>
                )}
                {status === false && (
                  <span style={{ fontSize: 10, padding: "2px 8px", borderRadius: 12, background: `${C.textMuted}20`, color: C.textMuted, fontWeight: 600 }}>Not Connected</span>
                )}
              </div>
              <div style={{ fontSize: 12, color: C.textMuted, marginTop: 4 }}>{description}</div>
              {status && status.lastSync && (
                <div style={{ fontSize: 11, color: C.textDim, marginTop: 4 }}>
                  Last sync: {status.lastSync.toDate ? status.lastSync.toDate().toLocaleString() : "Unknown"}
                </div>
              )}
              {status && status.athleteName && (
                <div style={{ fontSize: 11, color: C.textDim, marginTop: 4 }}>Athlete: {status.athleteName}</div>
              )}
            </div>
            <div style={{ display: "flex", gap: 8 }}>
              {status && status !== false && onSync && (
                <button onClick={onSync} disabled={syncingThis} style={{
                  padding: "8px 16px", borderRadius: 8, border: `1px solid ${C.border}`,
                  background: "transparent", color: syncingThis ? C.textMuted : C.textDim,
                  fontSize: 12, cursor: syncingThis ? "not-allowed" : "pointer", fontFamily: "'IBM Plex Mono',monospace",
                }}>
                  {syncingThis ? "Syncing..." : "Sync Now"}
                </button>
              )}
              {status === false && (
                <button onClick={onConnect} disabled={!!syncingThis} style={{
                  padding: "8px 16px", borderRadius: 8, border: "none",
                  background: color || C.cyanMuted, color: "#fff",
                  fontSize: 12, fontWeight: 600, cursor: "pointer", fontFamily: "'IBM Plex Mono',monospace",
                }}>
                  Connect
                </button>
              )}
            </div>
          </div>
        </Card>
      );

      // effectiveHRResult: use athleteProfile from DataProvider (single source of truth).
      // Preserves analyzedAt from stored hrResult for display purposes.
      const effectiveHRResult = useMemo(() => {
        if (!athleteProfile) return hrResult || null;
        return { ...athleteProfile, analyzedAt: hrResult?.analyzedAt };
      }, [athleteProfile, hrResult]);

      return (
        <div>
          <h2 style={{ fontFamily: "'Space Grotesk',sans-serif", fontSize: 24, fontWeight: 900, color: C.text, marginBottom: 8 }}>Settings</h2>
          <p style={{ color: C.textMuted, fontSize: 13, marginBottom: 24 }}>Manage your data connections and integrations.</p>

          {error && (
            <Card style={{ marginBottom: 16, border: `1px solid ${C.red}40`, background: `${C.red}10` }}>
              <div style={{ color: C.red, fontSize: 13 }}>{error}</div>
            </Card>
          )}
          {success && (
            <Card style={{ marginBottom: 16, border: `1px solid ${C.green}40`, background: `${C.green}10` }}>
              <div style={{ color: C.green, fontSize: 13 }}>{success}</div>
            </Card>
          )}
          {/* Auto-Sync Extension */}
          {(mfpBookmarkletToken || garminSyncKey) && (
            <Card style={{ marginBottom: 16, borderColor: C.cyan + "40" }}>
              <div style={{ display: "flex", alignItems: "center", gap: 16 }}>
                <div style={{ fontSize: 32, width: 48, textAlign: "center" }}>🔄</div>
                <div style={{ flex: 1 }}>
                  <div style={{ fontWeight: 700, fontSize: 15, color: C.text, marginBottom: 4 }}>Auto-Sync Extension</div>
                  <div style={{ fontSize: 12, color: C.textMuted, lineHeight: 1.5 }}>
                    Install once — MFP diary auto-syncs on every page visit, and Garmin Connect auto-syncs once a day when you open it (zero clicks). A floating "Sync" button is always there for an on-demand pull.
                  </div>
                </div>
                <button onClick={() => {
                  const script = `// ==UserScript==
// @name         chris.run Auto-Sync
// @namespace    https://chris.run
// @version      3.19
// @description  Auto-sync MFP nutrition and Garmin health data
// @match        https://www.myfitnesspal.com/food/diary*
// @match        https://connect.garmin.com/*
// @grant        none
// @run-at       document-start
// ==/UserScript==

(function() {
  'use strict';

  // Endurance Score & Hill Score passive capture. Garmin's SPA loads the
  // full series when you open those charts; keep the richest payload seen
  // on window.__crEnduranceData / __crHillData so the sync can replay it
  // — mirrors the VO2max/maxmet capture. Garmin's own auth is used, so
  // this works even when our active /gc-api/ fetch is rejected.
  var _crScoreLen = function(d){
    if (!d || typeof d !== "object") return 0;
    if (Array.isArray(d)) return d.length;
    if (d.groupMap && typeof d.groupMap === "object") return Object.keys(d.groupMap).length;
    for (var k in d) { if (Array.isArray(d[k])) return d[k].length; }
    return 1;
  };
  var _crCaptureScore = function(loweredUrl, d){
    if (!d || typeof d !== "object") return;
    if (loweredUrl.indexOf("endurancescore") >= 0) {
      if (_crScoreLen(d) >= _crScoreLen(window.__crEnduranceData)) window.__crEnduranceData = d;
    } else if (loweredUrl.indexOf("hillscore") >= 0) {
      if (_crScoreLen(d) >= _crScoreLen(window.__crHillData)) window.__crHillData = d;
    }
  };

  // Intercept Garmin Connect's own weight API calls passively as you browse.
  // The SPA's internal fetch uses auth tokens we can't replicate; capturing its
  // responses is more reliable than making our own requests.
  if (!window.__crWeightData) {
    window.__crWeightData = null;
    var _f0 = window.fetch;
    // Score a captured response by how rich/list-y it is — we want to keep
    // the broadest list response and not let a later single-entry call from
    // /weight/{date} or /weight/latest overwrite the dateRange list.
    var _crScoreWeight = function(d){
      if (!d || typeof d !== "object") return -1;
      if (Array.isArray(d.dailyWeightSummaries)) return d.dailyWeightSummaries.length * 100 + 50;
      if (Array.isArray(d.dateWeightList))       return d.dateWeightList.length       * 100 + 40;
      if (Array.isArray(d.weightSummaries))      return d.weightSummaries.length      * 100 + 30;
      if (Array.isArray(d.allWeightMetrics))     return d.allWeightMetrics.length     * 100 + 20;
      if (Array.isArray(d))                      return d.length                      * 100 + 10;
      // Single-entry shape (top-level weight field) — lowest priority but
      // still useful if nothing better arrives.
      if (typeof d.weight === "number" || typeof d.weight === "string") return 1;
      return 0;
    };
    window.fetch = async function() {
      var res = await _f0.apply(this, arguments);
      var u = (typeof arguments[0] === "string" ? arguments[0] : (arguments[0]&&arguments[0].url)||"");
      if (u.includes("weight-service") || u.includes("biometric-service")) {
        try {
          var clone = res.clone();
          var d = await clone.json();
          if (d && typeof d === "object" && Object.keys(d).length > 0) {
            var prevScore = _crScoreWeight(window.__crWeightData);
            var newScore  = _crScoreWeight(d);
            if (newScore > prevScore) window.__crWeightData = d;
          }
        } catch(e) {}
      }
      // VO2max long-term series — same passive-capture pattern as
      // weight-service above. When the user loads the Races / VO₂ Max
      // chart in Garmin Connect (any range — 4w, 12w, 6m), the SPA
      // fires an authenticated metrics-service/maxmet call we can't
      // replicate from the server. Intercept the response and keep
      // the longest list we see (the 6m view delivers ~180 entries,
      // the 4w view ~28; we always want the wider history).
      if (u.includes("metrics-service") && u.includes("maxmet")) {
        try {
          var mmClone = res.clone();
          var mmData = await mmClone.json();
          var mmArr = Array.isArray(mmData) ? mmData
                    : (mmData && Array.isArray(mmData.generic) ? mmData.generic
                    : (mmData && Array.isArray(mmData.maxMetCategories) ? mmData.maxMetCategories
                    : null));
          var mmLen = mmArr ? mmArr.length : (mmData && typeof mmData === "object" ? 1 : 0);
          var prevLen = window.__crMaxMetData
            ? (Array.isArray(window.__crMaxMetData)
                ? window.__crMaxMetData.length
                : (Array.isArray(window.__crMaxMetData.generic) ? window.__crMaxMetData.generic.length : 1))
            : 0;
          if (mmLen > prevLen) window.__crMaxMetData = mmData;
        } catch(e) {}
      }
      // Endurance Score & Hill Score series — passive capture, same
      // pattern as maxmet. The SPA fires these when their charts open.
      var _luSc = u.toLowerCase();
      if (_luSc.indexOf("endurancescore") >= 0 || _luSc.indexOf("hillscore") >= 0) {
        try { _crCaptureScore(_luSc, await res.clone().json()); } catch(e) {}
      }
      return res;
    };
  }

  // Step 1 diagnostic (fetch-xhr-implementation): Garmin's SPA uses
  // XMLHttpRequest for most API calls, so the fetch wrapper above is blind
  // to them. Passively capture method, URL, and request-header set on any
  // /gc-api/, /proxy/, or /workout-service/ call. No behavior change — the
  // captured data is parked on window.__crLastGarminHeaders + logged to the
  // console so we can see exactly which auth/CSRF headers Garmin attaches.
  // Open https://connect.garmin.com/modern/workouts then run
  // window.__crDumpGarminHeaders() in DevTools to dump the full map.
  if (!window.__crXhrLogged) {
    window.__crXhrLogged = true;
    window.__crLastGarminHeaders = window.__crLastGarminHeaders || {};
    var _xOpen = XMLHttpRequest.prototype.open;
    var _xSet = XMLHttpRequest.prototype.setRequestHeader;
    var _xSend = XMLHttpRequest.prototype.send;
    XMLHttpRequest.prototype.open = function(method, url) {
      this.__crMethod = method;
      this.__crUrl = url;
      this.__crHeaders = {};
      return _xOpen.apply(this, arguments);
    };
    XMLHttpRequest.prototype.setRequestHeader = function(name, value) {
      if (this.__crHeaders) this.__crHeaders[name] = value;
      return _xSet.apply(this, arguments);
    };
    XMLHttpRequest.prototype.send = function(body) {
      var u = this.__crUrl || "";
      // Endurance/Hill score series usually load via XHR — the header-only
      // capture below misses the body, so grab the response on load. Host-
      // agnostic (matches connectapi.garmin.com too).
      var _luX = String(u).toLowerCase();
      if (_luX.indexOf("endurancescore") >= 0 || _luX.indexOf("hillscore") >= 0) {
        this.addEventListener("load", function(){
          try { _crCaptureScore(_luX, JSON.parse(this.responseText)); } catch(e) {}
        });
      }
      if (u.indexOf("/gc-api/") >= 0 || u.indexOf("/proxy/") >= 0 || u.indexOf("/workout-service/") >= 0) {
        var key = String(u).split("?")[0];
        window.__crLastGarminHeaders[key] = Object.assign({}, this.__crHeaders);
        console.log("[chris.run xhr]", this.__crMethod, u, this.__crHeaders);
        /* Phase 1 ground-truth capture: log POST bodies to
           /workout-service/ paths so we can see EXACTLY what Garmin's
           own SPA sends when the user creates a workout via the UI.
           We need this to learn the canonical HR + pace target
           encoding (Garmin's 400 BadRequestException gives no field
           detail, so trial-and-error is impractical). The captured
           body is parked on window.__crGarminWorkoutBodies for easy
           DevTools inspection. */
        if (this.__crMethod === "POST" && u.indexOf("/workout-service/workout") >= 0 && body) {
          try {
            var parsed = typeof body === "string" ? JSON.parse(body) : body;
            window.__crGarminWorkoutBodies = window.__crGarminWorkoutBodies || [];
            window.__crGarminWorkoutBodies.push({ when: Date.now(), method: this.__crMethod, url: u, body: parsed });
            console.log("[chris.run capture] Garmin SPA workout POST body:", parsed);
          } catch(e) { console.warn("[chris.run capture] could not parse XHR body:", e); }
        }
      }
      return _xSend.apply(this, arguments);
    };
  }

  // Step 1 diagnostic: same idea for fetch-based Garmin API calls. The
  // weight wrapper above only clones responses — this one surfaces request
  // headers. Guarded separately so it layers cleanly on top of the existing
  // wrapper without double-capturing weight data.
  if (!window.__crFetchLogged) {
    window.__crFetchLogged = true;
    var _f1 = window.fetch;
    window.fetch = function() {
      var u = (typeof arguments[0] === "string" ? arguments[0] : (arguments[0] && arguments[0].url) || "");
      if (u.indexOf("/gc-api/") >= 0 || u.indexOf("/proxy/") >= 0 || u.indexOf("/workout-service/") >= 0) {
        var hdr = {};
        var opts = arguments[1];
        var reqHeaders = (opts && opts.headers) || (arguments[0] && arguments[0].headers);
        if (reqHeaders) {
          if (typeof reqHeaders.forEach === "function") {
            reqHeaders.forEach(function(v, k){ hdr[k] = v; });
          } else {
            Object.keys(reqHeaders).forEach(function(k){ hdr[k] = reqHeaders[k]; });
          }
        }
        var key = String(u).split("?")[0];
        window.__crLastGarminHeaders = window.__crLastGarminHeaders || {};
        // Don't clobber XHR captures with empty fetch headers — fetch calls
        // without explicit headers inherit Garmin's defaults via the browser.
        if (!window.__crLastGarminHeaders[key] || Object.keys(hdr).length > 0) {
          window.__crLastGarminHeaders[key] = hdr;
        }
        var method = (opts && opts.method) || "GET";
        console.log("[chris.run fetch]", method, u, hdr);
        /* Phase 1 ground-truth capture: same as XHR wrapper above —
           log fetch POST bodies to /workout-service/ so we can see
           Garmin's canonical HR + pace target encoding. */
        if (method === "POST" && u.indexOf("/workout-service/workout") >= 0 && opts && opts.body) {
          try {
            var parsed = typeof opts.body === "string" ? JSON.parse(opts.body) : opts.body;
            window.__crGarminWorkoutBodies = window.__crGarminWorkoutBodies || [];
            window.__crGarminWorkoutBodies.push({ when: Date.now(), method: method, url: u, body: parsed });
            console.log("[chris.run capture] Garmin SPA workout POST body:", parsed);
          } catch(e) { console.warn("[chris.run capture] could not parse fetch body:", e); }
        }
      }
      return _f1.apply(this, arguments);
    };
  }

  // Convenience: copy the most recently captured Garmin workout POST
  // body to the clipboard as pretty-printed JSON. Run this in DevTools
  // after creating a workout in Garmin Connect's UI to capture the
  // canonical HR + pace target encoding.
  window.__crDumpGarminWorkoutBody = function(idx) {
    var arr = window.__crGarminWorkoutBodies || [];
    if (arr.length === 0) { console.warn("No workout bodies captured. Create a workout in Garmin's UI first."); return; }
    var entry = arr[typeof idx === "number" ? idx : arr.length - 1];
    var json = JSON.stringify(entry.body, null, 2);
    console.log("=== chris.run: captured Garmin SPA workout body (" + arr.length + " total) ===");
    console.log(json);
    try { copy(json); console.log("Copied to clipboard."); } catch(e) {}
    return entry;
  };

  // Convenience dumper: prints the captured header map grouped by path so
  // it's easy to copy+paste back into the chris.run thread.
  window.__crDumpGarminHeaders = function() {
    var m = window.__crLastGarminHeaders || {};
    console.log("=== chris.run: captured Garmin headers (" + Object.keys(m).length + " paths) ===");
    Object.keys(m).sort().forEach(function(k){
      console.log(k);
      console.log(m[k]);
    });
    return m;
  };

  var uid = "${userId}";
  var mfpToken = "${mfpBookmarkletToken || ""}";
  var mfpUsername = "${mfpStatus?.username || ""}";
  var garminKey = "${garminSyncKey || ""}";
  var mfpEndpoint = "https://us-central1-cooke-financial-hub-942db.cloudfunctions.net/mfpBookmarkletSync";
  var garminEndpoint = "https://us-central1-cooke-financial-hub-942db.cloudfunctions.net/garminImport";

  // Create floating sync button
  var btn = document.createElement("div");
  btn.style.cssText = "position:fixed;bottom:20px;right:20px;z-index:99999;background:#06b6d4;color:#fff;padding:12px 20px;border-radius:12px;font-family:sans-serif;font-size:14px;font-weight:700;cursor:pointer;box-shadow:0 4px 20px rgba(0,0,0,.3);user-select:none;transition:all 0.2s";
  btn.onmouseover = function(){ btn.style.transform="scale(1.05)"; btn.style.background="#0891b2" };
  btn.onmouseout = function(){ btn.style.transform="scale(1)"; btn.style.background="#06b6d4" };

  var isMFP = location.hostname.includes("myfitnesspal.com");
  var isGarmin = location.hostname.includes("garmin.com");
  console.log("[chris.run] uid="+uid+" garminKey="+garminKey+" isGarmin="+isGarmin);

  /* ── MFP: shared parser & helpers ── */
  /* Read the FIRST number in a cell, not every digit in it. The old
     helper stripped all non-numeric characters and parsed what was left,
     so a cell rendering "48g" beside its percent-of-goal badge ("96%")
     collapsed to "4896" — a clean 100x inflation of the macro, which is
     exactly the corruption that reached Firestore. Matching one numeric
     token is immune to however many extra numbers MFP puts in the cell. */
  function crNum(t){
    var m=String(t==null?"":t).replace(/,/g,"").match(/-?\\d+(?:\\.\\d+)?/);
    return m?parseFloat(m[0]):0;
  }
  var g = function(x){ return crNum(x&&x.textContent) };

  /* MFP lets the athlete reorder and choose diary columns, so fixed
     indices silently swap protein with carbs on a customised diary. Read
     the header row and fall back to the stock order only if it can't be
     understood. */
  function crColMap(table){
    var map={calories:1,carbs:2,fat:3,protein:4,fiber:-1};
    if(!table)return map;
    var head=table.querySelector("thead tr")||table.querySelector("tr");
    if(!head)return map;
    var cells=head.querySelectorAll("th,td"),found={};
    for(var i=0;i<cells.length;i++){
      var t=(cells[i].textContent||"").toLowerCase();
      if(/calorie|kcal/.test(t)&&found.calories==null)found.calories=i;
      else if(/carb/.test(t)&&found.carbs==null)found.carbs=i;
      else if(/protein/.test(t)&&found.protein==null)found.protein=i;
      else if(/fiber|fibre/.test(t)&&found.fiber==null)found.fiber=i;
      else if(/\\bfat\\b/.test(t)&&found.fat==null)found.fat=i;
    }
    if(found.calories!=null&&found.carbs!=null&&found.fat!=null&&found.protein!=null){
      map.calories=found.calories;map.carbs=found.carbs;map.fat=found.fat;map.protein=found.protein;
      map.fiber=found.fiber!=null?found.fiber:-1;
    }
    return map;
  }

  function parseMfpPage(doc){
    var items=[],meals={},totals={calories:0,carbs:0,fat:0,protein:0,fiber:0},meal="";
    var table=doc.querySelector("#diary-table")||doc.querySelector("table.table0")||doc.querySelector("table");
    var cols=crColMap(table);
    var cell=function(c,idx){return idx>=0&&c[idx]?crNum(c[idx].textContent):0};
    var rows=doc.querySelectorAll("#diary-table tr, table.table0 tr");
    if(rows.length===0)rows=doc.querySelectorAll("table tr");
    rows.forEach(function(r){
      var txt=(r.querySelector("td:first-child")||{}).textContent||"";
      var txtLower=txt.toLowerCase();
      if(r.classList.contains("meal_header")){meal=txt.trim()}
      else if(txt.trim()==="Totals"&&!txtLower.includes("goal")&&!txtLower.includes("remaining")){
        var tc=r.querySelectorAll("td");
        totals={calories:cell(tc,cols.calories),carbs:cell(tc,cols.carbs),fat:cell(tc,cols.fat),protein:cell(tc,cols.protein),fiber:cell(tc,cols.fiber)};
      }else if(txtLower.includes("goal")||txtLower.includes("remaining")){/* skip */}
      else{
        var c=r.querySelectorAll("td");if(c.length>=5){var link=c[0].querySelector("a");var name=link?(link.textContent||"").trim():"";
          if(name&&name!=="Add Food"&&name!=="Quick Tools"){items.push({meal:meal,name:name,calories:cell(c,cols.calories),carbs:cell(c,cols.carbs),fat:cell(c,cols.fat),protein:cell(c,cols.protein),fiber:cell(c,cols.fiber)});if(!meals[meal])meals[meal]=[];meals[meal].push(items[items.length-1])}}
      }
    });
    if(items.length>0){var ic=items.reduce(function(s,it){return s+it.calories},0);
      if(ic>0&&Math.abs(ic-totals.calories)/ic>0.15){totals={calories:ic,protein:items.reduce(function(s,it){return s+it.protein},0),carbs:items.reduce(function(s,it){return s+it.carbs},0),fat:items.reduce(function(s,it){return s+it.fat},0),fiber:items.reduce(function(s,it){return s+(it.fiber||0)},0)}}}
    return {items:items,meals:meals,totals:totals};
  }

  /* Local calendar date. toISOString() is UTC, so every evening west of
     Greenwich it names TOMORROW — which is how a day's food kept landing
     on the next day's document. The diary is a local-time concept. */
  function crLocalDate(d){
    var x=d||new Date();
    return x.getFullYear()+"-"+String(x.getMonth()+1).padStart(2,"0")+"-"+String(x.getDate()).padStart(2,"0");
  }

  function getMfpDate(){
    var m=location.search.match(/date=(\\d{4}-\\d{2}-\\d{2})/);
    if(m)return m[1];
    return crLocalDate();
  }

  async function syncMfpDay(doc,ds){
    var p=parseMfpPage(doc);
    if(p.totals.calories<=0)return false;
    var payload={token:mfpToken,uid:uid,date:ds,meals:p.meals,totals:p.totals,items:p.items.length>0?p.items:[{meal:"Total",name:"Daily Total",calories:p.totals.calories,carbs:p.totals.carbs,fat:p.totals.fat,protein:p.totals.protein,fiber:p.totals.fiber||0}]};
    var r=await fetch(mfpEndpoint,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(payload)});
    var j=await r.json();return j.success;
  }

  function showToast(msg,color){
    var t=document.createElement("div");
    t.style.cssText="position:fixed;top:16px;right:16px;z-index:99999;background:"+(color||"#059669")+";color:#fff;padding:12px 20px;border-radius:10px;font-family:sans-serif;font-size:13px;font-weight:600;box-shadow:0 4px 16px rgba(0,0,0,.25);opacity:0;transition:opacity 0.3s";
    t.textContent=msg;document.body.appendChild(t);
    setTimeout(function(){t.style.opacity="1"},50);
    setTimeout(function(){t.style.opacity="0";setTimeout(function(){t.remove()},400)},3500);
  }

  if (isMFP && mfpToken && mfpUsername) {
    /* Auto-sync current page on load (zero-touch) */
    var autoSynced=false;
    function tryAutoSync(){
      if(autoSynced)return;
      var rows=document.querySelectorAll("#diary-table tr, table.table0 tr, table tr");
      if(rows.length<3){setTimeout(tryAutoSync,1000);return}
      autoSynced=true;
      var ds=getMfpDate();
      console.log("[chris.run] Auto-syncing "+ds+"...");
      syncMfpDay(document,ds).then(function(ok){
        if(ok){showToast("\\u2713 "+ds+" synced to chris.run","#059669");console.log("[chris.run] Auto-synced "+ds)}
      }).catch(function(e){console.error("[chris.run] Auto-sync error:",e)});
    }
    if(document.readyState==="complete"||document.readyState==="interactive"){setTimeout(tryAutoSync,1500)}
    else{window.addEventListener("DOMContentLoaded",function(){setTimeout(tryAutoSync,1500)})}

    /* Manual button for 7-day backfill */
    btn.textContent = "\\u26A1 Sync 7 days";
    btn.onclick = async function() {
      btn.textContent = "Syncing...";
      btn.style.background = "#f59e0b";
      var saved=0;
      for(var i=0;i<7;i++){
        var d=new Date();d.setDate(d.getDate()-i);
        var ds=crLocalDate(d);
        btn.textContent="Syncing "+(i+1)+"/7...";
        try{
          /* Only reuse the rendered page when it is actually showing the
             day we're about to write. Backfilling from a diary opened on
             some other date used to file that date's food under today. */
          var doc;
          if(getMfpDate()===ds){doc=document}
          else{var resp=await fetch("/food/diary/"+mfpUsername+"?date="+ds,{credentials:"include"});var html=await resp.text();doc=new DOMParser().parseFromString(html,"text/html")}
          if(await syncMfpDay(doc,ds))saved++;
        }catch(e){console.error("[chris.run] Error "+ds+":",e)}
      }
      btn.textContent="\\u2713 Synced "+saved+" days!";
      btn.style.background="#059669";
      showToast("Synced "+saved+" days to chris.run","#059669");
      setTimeout(function(){btn.textContent="\\u26A1 Sync 7 days";btn.style.background="#06b6d4"},4000);
    };
    document.body.appendChild(btn);
  }

  if (isGarmin) {
    /* chris.run workout push — if the URL carries crPushToken=XXX anywhere
       (set by the Plan-tab "Push Week (via browser)" button), fetch the
       staged week of workouts from chris.run, POST each to Garmin's own
       /workout-service endpoint using this tab's logged-in session
       cookies, then report results back. No OAuth, no MFA, no IP rate
       limits — Garmin sees a normal logged-in user creating workouts.
       Runs BEFORE the regular sync-button branch so if both a sync
       button and a push token are present we do the push first.

       Match against location.href (not just hash or search) because
       Garmin's SPA has been observed transforming /#crPushToken=X into
       /app/crPushToken=X — the token can end up in the path. */
    var pushTokenMatch = (location.href || "").match(/crPushToken=([a-z2-9]{16,})/i);
    var pushToken = pushTokenMatch ? pushTokenMatch[1] : null;
    console.log("[chris.run push] document-start on " + location.href + " · token detected: " + (pushToken ? "yes" : "no"));
    if (pushToken) {
      var fetchUrl = "https://us-central1-cooke-financial-hub-942db.cloudfunctions.net/garminWorkoutSyncFetch?token=" + pushToken;
      var reportUrl = "https://us-central1-cooke-financial-hub-942db.cloudfunctions.net/garminWorkoutSyncReport";
      // ghdr is populated inside the IIFE below after we've waited for
      // Garmin's SPA to fire its own /gc-api/ XHRs so our wrapper can
      // observe the session's auth/CSRF headers (Step 3 + 4 of
      // fetch-xhr-implementation).
      var ghdr = null;
      var overlay = document.createElement("div");
      overlay.style.cssText = "position:fixed;top:20px;right:20px;z-index:99999;background:#0b1220;color:#fff;padding:20px 24px;border-radius:12px;font-family:sans-serif;font-size:14px;line-height:1.5;box-shadow:0 8px 32px rgba(0,0,0,.5);max-width:360px;border:2px solid #06b6d4";
      overlay.innerHTML = "<div style='font-weight:700;margin-bottom:8px;font-size:13px;color:#06b6d4'>chris.run → Garmin</div><div id='crPushStatus'>Starting workout push...</div>";
      // @run-at document-start can fire before <body> exists, in which
      // case appendChild silently throws and the overlay never appears.
      // Wait for body if needed.
      var attachOverlay = function(){
        if (document.body) { document.body.appendChild(overlay); }
        else if (document.documentElement) { document.documentElement.appendChild(overlay); }
        else { setTimeout(attachOverlay, 50); }
      };
      attachOverlay();
      var setStatus = function(msg){ var el=document.getElementById("crPushStatus"); if(el) el.textContent = msg; };
      // Pluck session-scoped auth/CSRF headers from the captured XHR/fetch
      // map. Garmin's auth/CSRF is user-session-scoped (not per-endpoint),
      // so a header seen on /gc-api/user-settings works equally for
      // /gc-api/workout-service/workout. Prefer workout-service-scoped
      // captures, else any /gc-api/ entry. All keys lowercased — HTTP
      // headers are case-insensitive and this avoids NK+nk duplication.
      var WANTED = ["authorization","di-backend","x-csrf-token","connect-csrf-token","x-lang","x-app-ver","x-nk","nk"];
      var pickSessionHdrs = function(){
        var map = window.__crLastGarminHeaders || {};
        var keys = Object.keys(map);
        var preferred = keys.filter(function(k){ return k.indexOf("/workout-service/") >= 0; });
        var fallback  = keys.filter(function(k){ return k.indexOf("/gc-api/") >= 0; });
        var pool = preferred.length ? preferred : fallback;
        var out = {};
        pool.forEach(function(k){
          var h = map[k] || {};
          Object.keys(h).forEach(function(name){
            var lower = name.toLowerCase();
            if (WANTED.indexOf(lower) >= 0 && !(lower in out)) out[lower] = h[name];
          });
        });
        // Garmin's SPA migrated off bearer tokens — auth is now session-cookie
        // + connect-csrf-token. Either header proves we observed an
        // authenticated XHR; cookies ride along automatically via
        // credentials:"include".
        var hasAuth = !!(out.authorization || out["connect-csrf-token"]);
        return { hdrs: out, paths: pool, totalPaths: keys.length, hasAuth: hasAuth };
      };

      (async function(){
        try {
          // Wait for Garmin's SPA to fire an authenticated XHR so our
          // wrapper can observe the CSRF token. The old check (any
          // /gc-api/ URL seen) was too loose — static .properties GETs
          // hit /gc-api/ without session-scoped headers and let the
          // push proceed with an unusable header set, which Garmin
          // silently 200s with an empty body. Garmin migrated off
          // bearer tokens, so the gate is now Connect-Csrf-Token (or
          // a legacy Authorization header if the SPA ever swings back).
          // 20s cap.
          setStatus("Waiting for Garmin session headers...");
          var waitStart = Date.now();
          var picked = pickSessionHdrs();
          while (!picked.hasAuth && Date.now() - waitStart < 20000) {
            await new Promise(function(r){ setTimeout(r, 200); });
            picked = pickSessionHdrs();
          }
          console.log("[chris.run push] captured session headers:", picked.hdrs, "from", picked.paths.length, "scoped paths /", picked.totalPaths, "total");

          if (!picked.hasAuth) {
            // No authenticated XHR observed. Dump what we DID capture so
            // the failure is diagnosable from the overlay + console,
            // then bail out with an actionable instruction. Without a
            // CSRF token the POST would be silently accepted and
            // no-op'd by Garmin (empty {} response), which looks like
            // "nothing happens" to the user.
            var capturedSummary = Object.keys(window.__crLastGarminHeaders || {}).slice(0, 20).join(" | ") || "(none)";
            console.warn("[chris.run push] no session token captured. Paths seen so far:", Object.keys(window.__crLastGarminHeaders || {}));
            throw new Error(
              "Garmin session token not captured after 20s. " +
              "Click the Garmin logo or open Training → Calendar in THIS tab (don't reload), " +
              "then re-click Push Week on chris.run. " +
              "Seen " + (window.__crLastGarminHeaders ? Object.keys(window.__crLastGarminHeaders).length : 0) + " XHR paths, none with session token."
            );
          }

          ghdr = Object.assign(
            {"NK":"NT","X-Requested-With":"XMLHttpRequest","Accept":"application/json","Content-Type":"application/json"},
            picked.hdrs
          );
          console.log("[chris.run push] final request header keys:", Object.keys(ghdr));

          setStatus("Fetching week's workouts from chris.run...");
          var pResp = await fetch(fetchUrl);
          if (!pResp.ok) throw new Error("fetch " + pResp.status + " " + (await pResp.text()).slice(0,200));
          var plan = await pResp.json();
          var workouts = plan.workouts || [];
          if (workouts.length === 0) {
            setStatus("No runnable workouts this week. Nothing to push.");
            return;
          }
          var results = [];
          for (var i = 0; i < workouts.length; i++) {
            var w = workouts[i];
            setStatus("[" + (i+1) + "/" + workouts.length + "] " + w.displayName);
            var workoutId = null, scheduledDate = null, errMsg = null;
            try {
              // Delete the previous push (keeps Garmin library clean on re-pushes).
              // We don't know yet which base path works for this session, so
              // fire a DELETE against each candidate and verify the response
              // is a real API response (JSON or empty 204) — Garmin's SPA
              // proxies often return 200 OK with the HTML home page when a
              // path is wrong, which used to silently "succeed" the loop.
              var deletedPrevious = null;
              if (w.previousWorkoutId) {
                var delPaths = [
                  "/gc-api/workout-service/workout/",
                  "/proxy/workout-service/workout/",
                  "/workout-service/workout/",
                  "https://connect.garmin.com/gc-api/workout-service/workout/",
                  "https://connect.garmin.com/workout-service/workout/",
                  "https://connectapi.garmin.com/workout-service/workout/",
                ];
                var delErrs = [];
                for (var di = 0; di < delPaths.length; di++) {
                  try {
                    var dResp = await fetch(delPaths[di] + w.previousWorkoutId, {
                      method: "DELETE", credentials: "include", headers: ghdr
                    });
                    if (!dResp.ok) {
                      delErrs.push(delPaths[di] + " → " + dResp.status);
                      continue;
                    }
                    var dCT = (dResp.headers.get("content-type") || "").toLowerCase();
                    var dText = await dResp.text();
                    // Real API response: empty body (204), JSON content-type,
                    // or no body at all. HTML means we hit the SPA shell, not
                    // the API — keep trying other paths.
                    if (dResp.status === 204 || dText === "" || dCT.indexOf("json") !== -1) {
                      deletedPrevious = { id: w.previousWorkoutId, path: delPaths[di], status: dResp.status };
                      console.log("[chris.run push] " + w.dayName + " — deleted previous " + w.previousWorkoutId + " via " + delPaths[di]);
                      break;
                    }
                    delErrs.push(delPaths[di] + " → 200 HTML (SPA, not API)");
                  } catch(e) {
                    delErrs.push(delPaths[di] + " → fetch error: " + (e.message || e));
                  }
                }
                if (!deletedPrevious) {
                  console.warn("[chris.run push] " + w.dayName + " — could NOT delete previous workout " + w.previousWorkoutId + ": " + delErrs.join(" | "));
                }
              }
              /* Try multiple endpoint paths. Garmin recently migrated
                 /modern/ → /app/ and the old /proxy/workout-service/
                 path appears to silently 200 with the SPA's HTML home
                 page instead of routing to the API — requests look
                 successful but no workout is created. Try paths in
                 preference order and accept the first one that returns
                 an actual JSON-parsable API response (not an HTML
                 page). On success we unpack the workoutId via the
                 broader field-name search; on failure we collect the
                 status + body prefix so the error overlay tells us
                 exactly where each endpoint bailed. */
              var POST_PATHS = [
                /* Garmin's current SPA (post /modern/ → /app/ migration)
                   routes API calls through /gc-api/. Confirmed from a
                   DevTools capture showing the SPA itself hitting
                   /gc-api/calendar-service/event/primary. Try this first. */
                "/gc-api/workout-service/workout",
                "/proxy/workout-service/workout",
                "/workout-service/workout",
                "https://connect.garmin.com/gc-api/workout-service/workout",
                "https://connect.garmin.com/workout-service/workout",
                "https://connectapi.garmin.com/workout-service/workout",
              ];
              var created = null, cStatus = 0, rawText = "", pathHit = null, perPathErrs = [];
              for (var pi = 0; pi < POST_PATHS.length; pi++) {
                var p = POST_PATHS[pi];
                try {
                  var cResp2 = await fetch(p, {
                    method: "POST", credentials: "include", headers: ghdr,
                    body: JSON.stringify(w.payload),
                  });
                  cStatus = cResp2.status;
                  var cType = cResp2.headers.get("content-type") || "";
                  rawText = await cResp2.text();
                  /* Dump full request + response detail to console for any
                     non-success so the user can paste back exactly what
                     Garmin said. The overlay only shows ~400 chars per
                     endpoint to stay readable. */
                  if (!cResp2.ok || (rawText || "").trim() === "" || /<!doctype|<html/i.test((rawText || "").slice(0, 200))) {
                    var respHdrs = {};
                    try { cResp2.headers.forEach(function(v,k){ respHdrs[k] = v; }); } catch(_){}
                    console.error("[chris.run push] POST " + p + " → " + cStatus, {
                      requestHeaderKeys: Object.keys(ghdr),
                      requestBody: w.payload,
                      responseHeaders: respHdrs,
                      responseBody: rawText,
                    });
                  }
                  /* Reject SPA HTML masquerading as success */
                  var looksHtml = /<!doctype|<html/i.test((rawText || "").slice(0, 200)) || cType.indexOf("text/html") === 0;
                  if (!cResp2.ok) {
                    perPathErrs.push(p + " → " + cStatus + (rawText ? " " + rawText.slice(0,400) : ""));
                    continue;
                  }
                  if (looksHtml || (rawText || "").trim() === "") {
                    perPathErrs.push(p + " → " + cStatus + " [HTML/empty — SPA catch-all, not the API]");
                    continue;
                  }
                  try { created = JSON.parse(rawText); } catch(je) {
                    perPathErrs.push(p + " → " + cStatus + " [non-JSON body]");
                    continue;
                  }
                  /* An empty {} response with 2xx is what Garmin's /gc-api/
                     returns when auth/CSRF headers are missing — the
                     request parses but is silently no-op'd. Reject so we
                     try the next path OR fail loudly. */
                  if (!created || typeof created !== "object" || Array.isArray(created) || Object.keys(created).length === 0) {
                    perPathErrs.push(p + " → " + cStatus + " [empty JSON body — likely missing auth/CSRF header]");
                    created = null;
                    continue;
                  }
                  pathHit = p;
                  break; /* We got a parseable JSON response — use it */
                } catch (eFetch) {
                  perPathErrs.push(p + " → fetch error: " + (eFetch.message || eFetch));
                }
              }
              if (!created) {
                throw new Error("all endpoints failed: " + perPathErrs.join(" | "));
              }
              workoutId = created.workoutId
                || created.id
                || created.uuid
                || (created.workout && (created.workout.workoutId || created.workout.id))
                || null;
              if (!workoutId) {
                console.warn("[chris.run push] " + w.dayName + " created via " + pathHit + " but workoutId not found — response body:", rawText);
                results.push({ dayIdx: w.dayIdx, success: true, workoutId: null, scheduledDate: null, note: "no_id_returned", path: pathHit });
                continue; /* to next workout */
              }
              console.log("[chris.run push] " + w.dayName + " created via " + pathHit + " → workoutId " + workoutId);
              // Schedule onto the calendar date (best effort). Reuse whatever
              // prefix the create call worked on — derive "/workout-service/
              // schedule/{id}" from the matched pathHit so we don't re-try
              // dead endpoints.
              if (w.dateISO && pathHit) {
                var schedPath = pathHit.replace(/\\/workout-service\\/workout$/, "/workout-service/schedule/" + workoutId);
                try {
                  var sResp = await fetch(schedPath, {
                    method: "POST", credentials: "include", headers: ghdr,
                    body: JSON.stringify({ date: w.dateISO }),
                  });
                  if (sResp.ok) scheduledDate = w.dateISO;
                  else console.warn("[chris.run push] schedule " + schedPath + " → " + sResp.status);
                } catch(e) { console.warn("[chris.run push] schedule failed:", e); }
              }
              results.push({
                dayIdx: w.dayIdx, success: true,
                workoutId: workoutId, scheduledDate: scheduledDate,
                previousWorkoutId: w.previousWorkoutId || null,
                previousDeleted: !!deletedPrevious,
              });
            } catch(e) {
              console.error("[chris.run push] failed for " + w.dayName + ":", e);
              errMsg = String(e.message || e);
              results.push({
                dayIdx: w.dayIdx, success: false, error: errMsg,
                previousWorkoutId: w.previousWorkoutId || null,
                previousDeleted: !!deletedPrevious,
              });
            }
          }
          setStatus("Reporting " + results.length + " results to chris.run...");
          try {
            await fetch(reportUrl, {
              method: "POST", headers: {"Content-Type":"application/json"},
              body: JSON.stringify({ token: pushToken, results: results }),
            });
          } catch(e) { console.warn("[chris.run push] report failed:", e); }
          var pushed = results.filter(function(r){return r.success}).length;
          var scheduled = results.filter(function(r){return r.success && r.scheduledDate}).length;
          var failed = results.filter(function(r){return !r.success}).length;
          var ok = (pushed > 0);
          overlay.style.borderColor = ok ? "#10b981" : "#dc2626";
          var errSummary = "";
          if (failed > 0) {
            /* Group failures by first-100-chars of error so 5 identical errors
               show as "5× create 404 Not Found" instead of five lines. */
            var errGroups = {};
            results.forEach(function(r){
              if (r.success) return;
              var key = String(r.error || "unknown").slice(0, 120);
              errGroups[key] = (errGroups[key] || 0) + 1;
            });
            errSummary = "<div style='margin-top:10px;padding:8px;background:rgba(220,38,38,.15);border-radius:6px;font-size:11px;color:#fca5a5;max-height:160px;overflow:auto'>" +
              Object.keys(errGroups).map(function(k){
                return "<div style='margin-bottom:4px'><strong>" + errGroups[k] + "×</strong> " + k.replace(/</g,"&lt;") + "</div>";
              }).join("") +
              "</div>";
          }
          overlay.style.maxWidth = "480px";
          overlay.innerHTML =
            "<div style='font-weight:700;margin-bottom:8px;font-size:13px;color:" + (ok?"#10b981":"#dc2626") + "'>" +
            (ok ? "✓ Workouts pushed" : "✗ Push failed") + "</div>" +
            "<div>" + pushed + " pushed" + (scheduled ? ", " + scheduled + " scheduled" : "") + (failed ? ", " + failed + " failed" : "") + "</div>" +
            errSummary +
            "<div style='margin-top:8px;font-size:11px;color:#94a3b8'>Full errors in DevTools console. Check Garmin Connect → Training → Calendar. You can close this tab.</div>";
          /* Strip the token from the URL so a refresh doesn't re-run.
             Token can be in the query, hash, or (when Garmin's SPA mangles
             it) the path — strip all three by collapsing to "/". */
          try { history.replaceState(null, "", "/"); } catch(e) {}
        } catch(e) {
          console.error("[chris.run push] fatal:", e);
          console.warn("[chris.run push] captured header map at failure:", window.__crLastGarminHeaders);
          var _map = window.__crLastGarminHeaders || {};
          var _paths = Object.keys(_map);
          var _auth = _paths.some(function(k){ var h=_map[k]||{}; return Object.keys(h).some(function(n){var nl=n.toLowerCase();return nl==="authorization"||nl==="connect-csrf-token";}); });
          var _pathList = _paths.slice(0, 8).map(function(p){
            var last = p.split("/").slice(-2).join("/");
            return "<div style='font-family:monospace;font-size:10px;color:#94a3b8'>· " + last.replace(/</g,"&lt;") + "</div>";
          }).join("");
          if (_paths.length > 8) _pathList += "<div style='font-family:monospace;font-size:10px;color:#64748b'>… +" + (_paths.length - 8) + " more</div>";
          overlay.style.borderColor = "#dc2626";
          overlay.style.maxWidth = "480px";
          overlay.innerHTML =
            "<div style='font-weight:700;margin-bottom:8px;font-size:13px;color:#dc2626'>✗ Push failed</div>" +
            "<div>" + String(e.message || e).replace(/</g,"&lt;") + "</div>" +
            "<div style='margin-top:10px;padding:8px;background:rgba(15,23,42,.6);border-radius:6px;font-size:11px;color:#cbd5e1'>" +
              "<div>captured <strong>" + _paths.length + "</strong> XHR paths · session token seen: <strong style='color:" + (_auth?"#10b981":"#f87171") + "'>" + (_auth ? "yes" : "no") + "</strong></div>" +
              (_pathList ? "<div style='margin-top:6px;max-height:120px;overflow:auto'>" + _pathList + "</div>" : "") +
            "</div>" +
            "<div style='margin-top:8px;font-size:11px;color:#94a3b8'>Full dump in DevTools console (__crLastGarminHeaders). Close this tab and retry.</div>";
        }
      })();
      return; /* skip the sync button UI below */
    }

    /* chris.run library cleanup — same handshake pattern as push, but for
       bulk-deleting orphaned chris.run-pushed workouts from Garmin's library.
       Token in URL → fetch live IDs from chris.run → enumerate Garmin's
       library → DELETE anything matching pattern that isn't in liveIds. */
    var cleanupTokenMatch = (location.href || "").match(/crCleanupToken=([a-z2-9]{16,})/i);
    var cleanupToken = cleanupTokenMatch ? cleanupTokenMatch[1] : null;
    if (cleanupToken) {
      var cFetchUrl = "https://us-central1-cooke-financial-hub-942db.cloudfunctions.net/cleanGarminLibraryFetch?token=" + cleanupToken;
      var cReportUrl = "https://us-central1-cooke-financial-hub-942db.cloudfunctions.net/cleanGarminLibraryReport";
      var cOverlay = document.createElement("div");
      cOverlay.style.cssText = "position:fixed;top:20px;right:20px;z-index:99999;background:#0b1220;color:#fff;padding:20px 24px;border-radius:12px;font-family:sans-serif;font-size:14px;line-height:1.5;box-shadow:0 8px 32px rgba(0,0,0,.5);max-width:360px;border:2px solid #f59e0b";
      cOverlay.innerHTML = "<div style='font-weight:700;margin-bottom:8px;color:#f59e0b'>chris.run Garmin cleanup</div><div id='crCleanStatus'>Initializing...</div>";
      var cAttach = function(){
        if (document.body) document.body.appendChild(cOverlay);
        else if (document.documentElement) document.documentElement.appendChild(cOverlay);
        else setTimeout(cAttach, 50);
      };
      cAttach();
      var cSetStatus = function(msg){ var el=document.getElementById("crCleanStatus"); if(el) el.textContent = msg; };
      var cWANTED = ["authorization","di-backend","x-csrf-token","connect-csrf-token","x-lang","x-app-ver","x-nk","nk"];
      var cPickHdrs = function(){
        var map = window.__crLastGarminHeaders || {};
        var keys = Object.keys(map);
        var preferred = keys.filter(function(k){ return k.indexOf("/workout-service/") >= 0; });
        var fallback  = keys.filter(function(k){ return k.indexOf("/gc-api/") >= 0; });
        var pool = preferred.length ? preferred : fallback;
        var out = {};
        pool.forEach(function(k){
          var h = map[k] || {};
          Object.keys(h).forEach(function(name){
            var lower = name.toLowerCase();
            if (cWANTED.indexOf(lower) >= 0 && !(lower in out)) out[lower] = h[name];
          });
        });
        var hasAuth = !!(out.authorization || out["connect-csrf-token"]);
        return { hdrs: out, hasAuth: hasAuth };
      };

      (async function(){
        try {
          cSetStatus("Waiting for Garmin session headers...");
          var waitStart = Date.now();
          var picked = cPickHdrs();
          while (!picked.hasAuth && Date.now() - waitStart < 20000) {
            await new Promise(function(r){ setTimeout(r, 200); });
            picked = cPickHdrs();
          }
          if (!picked.hasAuth) {
            cSetStatus("Could not capture session headers (timeout). Make sure you're logged into Garmin Connect, then close this tab and retry from chris.run.");
            cOverlay.style.borderColor = "#dc2626";
            return;
          }
          var ghdr = picked.hdrs;
          ghdr["accept"] = "application/json";

          cSetStatus("Fetching live workout IDs from chris.run...");
          var live = await (await fetch(cFetchUrl, { method: "GET" })).json();
          if (!live || !Array.isArray(live.liveIds)) throw new Error("invalid live IDs response: " + JSON.stringify(live));
          var liveSet = {};
          live.liveIds.forEach(function(id){ liveSet[String(id)] = true; });
          var pattern = new RegExp(live.pattern);
          cSetStatus(live.liveIds.length + " live IDs to keep. Enumerating Garmin library...");

          /* Page through Garmin's workouts library. Reuse the path-probing
             pattern from the push side: try /gc-api first, fall back. */
          var LIST_PATHS = [
            "/gc-api/workout-service/workouts?start=",
            "/proxy/workout-service/workouts?start=",
          ];
          var DEL_PATHS = [
            "/gc-api/workout-service/workout/",
            "/proxy/workout-service/workout/",
          ];
          var matched = [], deleted = [], failed = [];
          var PAGE = 50;
          var listPathHit = null;

          for (var start = 0; start < 1000; start += PAGE) {
            var batch = null;
            for (var lpi = 0; lpi < LIST_PATHS.length; lpi++) {
              var lp = LIST_PATHS[lpi];
              if (listPathHit && lp !== listPathHit) continue;
              try {
                var lResp = await fetch(lp + start + "&limit=" + PAGE, {
                  method: "GET", credentials: "include", headers: ghdr
                });
                if (!lResp.ok) continue;
                var lCT = (lResp.headers.get("content-type") || "").toLowerCase();
                if (lCT.indexOf("json") === -1) continue; /* SPA HTML, not API */
                batch = await lResp.json();
                listPathHit = lp;
                break;
              } catch(e) { /* try next */ }
            }
            if (!Array.isArray(batch) || batch.length === 0) break;
            batch.forEach(function(w){
              var name = w.workoutName || w.name || "";
              var wid = String(w.workoutId || w.id || "");
              if (wid && pattern.test(name)) {
                matched.push({ workoutId: wid, name: name, isLive: !!liveSet[wid] });
              }
            });
            if (batch.length < PAGE) break;
          }

          var orphans = matched.filter(function(m){ return !m.isLive; });
          cSetStatus("Found " + matched.length + " chris.run workouts; " + orphans.length + " orphans to delete.");

          for (var oi = 0; oi < orphans.length; oi++) {
            var o = orphans[oi];
            cSetStatus("[" + (oi+1) + "/" + orphans.length + "] Deleting " + o.name);
            var success = false, derr = null;
            for (var dpi = 0; dpi < DEL_PATHS.length; dpi++) {
              try {
                var dResp = await fetch(DEL_PATHS[dpi] + o.workoutId, {
                  method: "DELETE", credentials: "include", headers: ghdr
                });
                if (!dResp.ok) { derr = dResp.status; continue; }
                var dCT = (dResp.headers.get("content-type") || "").toLowerCase();
                var dText = await dResp.text();
                if (dResp.status === 204 || dText === "" || dCT.indexOf("json") !== -1) {
                  success = true;
                  break;
                }
                derr = "200 HTML (SPA, not API)";
              } catch(e) { derr = String(e.message || e); }
            }
            if (success) deleted.push({ id: o.workoutId, name: o.name });
            else failed.push({ id: o.workoutId, name: o.name, error: derr });
          }

          cSetStatus("Reporting results to chris.run...");
          await fetch(cReportUrl, {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({
              token: cleanupToken,
              matched: matched.length,
              live: matched.length - orphans.length,
              deleted: deleted,
              failed: failed,
            }),
          });

          cSetStatus("✓ Done. Deleted " + deleted.length + " orphans (" + failed.length + " failed). " + (matched.length - orphans.length) + " live workouts kept.");
          cOverlay.style.borderColor = failed.length > 0 ? "#f59e0b" : "#10b981";
          setTimeout(function(){ if (cOverlay.parentNode) cOverlay.parentNode.removeChild(cOverlay); }, 12000);
        } catch (err) {
          cSetStatus("Cleanup failed: " + (err.message || err));
          cOverlay.style.borderColor = "#dc2626";
          console.error("[chris.run cleanup] failed:", err);
        }
      })();
      return; /* skip the sync button UI below */
    }

    if (!garminKey) {
      btn.textContent = "⚠ Re-download sync script";
      btn.style.background = "#dc2626";
      btn.onclick = function(){ alert("Sync key missing. Go to chris.run Settings and re-download the userscript."); };
      document.body.appendChild(btn);
    } else {
    btn.textContent = "⚡ Sync to chris.run";
    var doGarminSync = async function() {
      btn.textContent = "Syncing...";
      btn.style.background = "#f59e0b";
      try{
        var pResp=await fetch("/proxy/userprofile-service/userprofile/personal-information",{credentials:"include"});
        if(!pResp.ok){btn.textContent="Not logged in";btn.style.background="#dc2626";return}
        var profile=await pResp.json();
        var username=profile.displayName||profile.userName||"";
        // Capture Garmin's session-scoped auth headers (Connect-Csrf-Token
        // etc.) up-front — before any data fetch — so every call below can
        // replay them. Garmin is retiring the /proxy/ root for /gc-api/ and
        // requires the CSRF header on authenticated calls. Wait up to 8s for
        // the SPA to fire its own authenticated call so the wrapper sees it.
        var today=new Date().toISOString().split("T")[0];
        var clientDiag=[];
        var pickGarminHdrs=function(){
          var map=window.__crLastGarminHeaders||{};
          var keys=Object.keys(map).filter(function(k){
            return k.indexOf("/gc-api/")>=0||k.indexOf("/proxy/")>=0;
          });
          var WANTED=["connect-csrf-token","authorization","nk","x-app-ver","x-lang","x-nk","di-backend"];
          var out={};
          keys.forEach(function(k){
            var h=map[k]||{};
            Object.keys(h).forEach(function(name){
              var lower=name.toLowerCase();
              if(WANTED.indexOf(lower)>=0&&!(lower in out)) out[lower]=h[name];
            });
          });
          return { hdrs: out, pathCount: keys.length, hasAuth: !!(out["connect-csrf-token"]||out.authorization) };
        };
        var sessionHdrs={};
        var hdrWait=Date.now();
        var picked=pickGarminHdrs();
        while(!picked.hasAuth&&Date.now()-hdrWait<8000){
          await new Promise(function(r){setTimeout(r,250);});
          picked=pickGarminHdrs();
        }
        sessionHdrs=picked.hdrs;
        clientDiag.push("session hdrs: "+(picked.hasAuth?"captured":"missing")+" (csrf="+(sessionHdrs["connect-csrf-token"]?"y":"n")+", paths seen="+picked.pathCount+")");
        var ghdrFor=function(){
          return Object.assign(
            {"NK":"NT","X-Requested-With":"XMLHttpRequest","Accept":"application/json, text/javascript, */*"},
            sessionHdrs
          );
        };
        // Authenticated JSON GET — /gc-api/ first (current root), /proxy/
        // fallback (legacy), replaying the captured session headers. A 2xx
        // with an empty body counts as a miss so the next URL is tried. A
        // leading-slash path is used verbatim (no root prefixing).
        var gFetch=async function(path){
          var roots=path.charAt(0)==="/"?[""]:["/gc-api/","/proxy/"];
          for(var ri=0;ri<roots.length;ri++){
            try{
              var rr=await fetch(roots[ri]+path,{credentials:"include",headers:ghdrFor()});
              if(rr.ok){
                var jj=await rr.json();
                if(jj&&(Array.isArray(jj)?jj.length:Object.keys(jj).length)) return jj;
              }
            }catch(e){}
          }
          return null;
        };
        var dates={};
        for(var i=0;i<30;i++){
          var d=new Date();d.setDate(d.getDate()-i);var ds=d.toISOString().split("T")[0];
          btn.textContent="Fetching "+(i+1)+"/30...";
          var day={};
          try{var r=await fetch("/proxy/wellness-service/wellness/bodyBattery/reports/daily?startDate="+ds+"&endDate="+ds,{credentials:"include"});if(r.ok)day.bodyBattery=await r.json()}catch(e){}
          try{var r=await fetch("/proxy/hrv-service/hrv/"+ds,{credentials:"include"});if(r.ok)day.hrv=await r.json()}catch(e){}
          try{var r=await fetch("/proxy/wellness-service/wellness/dailyStress/"+ds,{credentials:"include"});if(r.ok)day.stress=await r.json()}catch(e){}
          try{var r=await fetch("/proxy/wellness-service/wellness/dailySleepData/"+username+"?date="+ds+"&nonSleepBufferMinutes=60",{credentials:"include"});if(r.ok)day.sleep=await r.json()}catch(e){}
          try{var r=await fetch("/proxy/wellness-service/wellness/daily/spo2/"+ds,{credentials:"include"});if(r.ok)day.spo2=await r.json()}catch(e){}
          try{var r=await fetch("/proxy/wellness-service/wellness/daily/respiration/"+ds,{credentials:"include"});if(r.ok)day.respiration=await r.json()}catch(e){}
          try{var r=await fetch("/proxy/wellness-service/wellness/dailyHeartRate/"+username+"?date="+ds,{credentials:"include"});if(r.ok)day.heartRate=await r.json()}catch(e){}
          try{var r=await fetch("/proxy/training-readiness-service/trainingreadiness/"+ds,{credentials:"include"});if(r.ok)day.trainingReadiness=await r.json()}catch(e){}
          // Daily VO2max — Firstbeat surfaces a 1dp value every day (incl.
          // rest days). Try both URL shapes Garmin has used over time.
          try{
            var mmUrls=["/proxy/metrics-service/metrics/maxmet/daily/"+ds,"/proxy/metrics-service/metrics/maxmet/"+ds];
            for(var mu=0;mu<mmUrls.length;mu++){
              try{var rr=await fetch(mmUrls[mu],{credentials:"include"});if(rr.ok){day.maxMet=await rr.json();break}}catch(e){}
            }
          }catch(e){}
          dates[ds]=day;
        }
        btn.textContent="Fetching body comp...";
        // Weight / body composition — direct authenticated fetch first
        // (90-day range, no page visit needed); fall back to the passively-
        // intercepted SPA payload, then to weight/latest.
        var weight=null, weightSrc="none";
        try{
          var wsd=new Date();wsd.setDate(wsd.getDate()-90);
          var wUrl="weight-service/weight/dateRange?startDate="+wsd.toISOString().split("T")[0]+"&endDate="+today;
          if(username)wUrl+="&displayName="+encodeURIComponent(username);
          weight=await gFetch(wUrl);
          if(weight)weightSrc="fetched";
        }catch(e){}
        if(!weight&&window.__crWeightData&&Object.keys(window.__crWeightData).length>0){
          weight=window.__crWeightData;weightSrc="captured";
        }
        if(!weight){
          var wl=await gFetch("weight-service/weight/latest");
          if(wl){weight={singleEntry:wl};weightSrc="latest";}
        }
        clientDiag.push("weight: "+weightSrc);
        if(!weight||Object.keys(weight).length===0){weight={_noData:true};}
        btn.textContent="Fetching performance stats...";
        var performance={};
        try{var r=await fetch("/proxy/fitnessstats-service/fitness",{credentials:"include"});if(r.ok)performance.fitness=await r.json()}catch(e){}
        try{var r=await fetch("/proxy/metrics-service/metrics/trainingstatus/aggregated/"+today,{credentials:"include"});if(r.ok)performance.trainingStatus=await r.json()}catch(e){}
        try{var r=await fetch("/proxy/metrics-service/metrics/racepredictions/"+today,{credentials:"include"});if(r.ok)performance.racePredictions=await r.json()}catch(e){}
        try{var r=await fetch("/proxy/metrics-service/metrics/lathr/"+today,{credentials:"include"});if(r.ok)performance.lactateThr=await r.json()}catch(e){}
        // Endurance & Hill score — direct authenticated fetch.
        // Ground-truth endpoint (observed from Garmin Connect 5.25's own
        // SPA call): metrics-service/metrics/{metric}/stats with the dates
        // as ?startDate=&endDate=&aggregation=daily QUERY params. Earlier
        // path-style guesses (/stats/{a}/{b}, /dateRange, /daily/{date})
        // all 404'd, which is why the scores never landed.
        var scEnd = today;
        var scStartD = new Date(); scStartD.setDate(scStartD.getDate() - 365);
        var scStart = scStartD.toISOString().split("T")[0];
        var sc28D = new Date(); sc28D.setDate(sc28D.getDate() - 28);
        var sc28 = sc28D.toISOString().split("T")[0];
        var scSpecs = [
          ["endurancescore", "enduranceScoreHistory", window.__crEnduranceData],
          ["hillscore", "hillScoreHistory", window.__crHillData],
        ];
        var scStatus = [];
        for (var sm=0; sm<scSpecs.length; sm++) {
          var got=false;
          var scPaths = [
            "metrics-service/metrics/"+scSpecs[sm][0]+"/stats?startDate="+scStart+"&endDate="+scEnd+"&aggregation=daily",
            "metrics-service/metrics/"+scSpecs[sm][0]+"/stats?startDate="+sc28+"&endDate="+scEnd+"&aggregation=daily"
          ];
          for (var su=0; su<scPaths.length; su++) {
            var sj = await gFetch(scPaths[su]);
            if (sj) { performance[scSpecs[sm][1]] = sj; got=true; break; }
          }
          // Fallback: the SPA payload we intercepted passively, if any.
          if (!got) {
            var captured = scSpecs[sm][2];
            if (captured && typeof captured === "object") {
              performance[scSpecs[sm][1]] = captured;
              scStatus.push(scSpecs[sm][0]+"=captured");
              continue;
            }
          }
          scStatus.push(scSpecs[sm][0]+"="+(got?"fetched":"n"));
        }
        clientDiag.push("scores: "+scStatus.join(", "));
        // VO2max long-term history — powers the trend chart. Prefer the
        // passively-intercepted SPA payload (known-good shape); fall back
        // to a direct authenticated fetch (maxmet/stats?, mirroring the
        // endurance/hill endpoint; then the older dateRange shape). Even
        // with no history, VO2max still accumulates one reading per sync.
        var voSrc="none";
        if (window.__crMaxMetData) {
          performance.maxMetHistory = window.__crMaxMetData; voSrc="captured";
        } else {
          var voStartD = new Date(); voStartD.setDate(voStartD.getDate() - 365);
          var voStart = voStartD.toISOString().split("T")[0];
          var voUrls = [
            "metrics-service/metrics/maxmet/stats?startDate="+voStart+"&endDate="+today+"&aggregation=daily",
            "metrics-service/metrics/maxmet/dateRange?startDate="+voStart+"&endDate="+today
          ];
          for (var vu=0; vu<voUrls.length; vu++) {
            var vj = await gFetch(voUrls[vu]);
            if (vj) { performance.maxMetHistory = vj; voSrc="fetched"; break; }
          }
        }
        clientDiag.push("vo2 history: "+voSrc);
        // Per-activity running dynamics — replaces the OAuth API path
        // that kept expiring. Server merges by startTimeGMT against
        // existing Strava activities (functions/index.js garminImport).
        // Garmin migrated the API root from /proxy/ → /gc-api/ (Connect
        // SPA 5.24+, /app/activities) AND now requires the
        // Connect-Csrf-Token header on every authenticated call.
        // Captured headers are pulled from window.__crLastGarminHeaders
        // populated by the XHR/fetch interceptor injected at script-start.
        btn.textContent="Fetching activities...";
        var activities=[];
        try{
          // Garmin SPA migrated to /gc-api/ as the new API root. Try the
          // new URL first, then fall back to /proxy/ for older SPA builds.
          var listUrls=[
            "/gc-api/activitylist-service/activities/search/activities?limit=30&start=0&activityType=running&_="+Date.now(),
            "/gc-api/activitylist-service/activities/search/activities?limit=30&start=0",
            "/proxy/activitylist-service/activities/search/activities?limit=30&start=0&activityType=running&_="+Date.now(),
            "/proxy/activitylist-service/activities/search/activities?limit=30&start=0"
          ];
          var rl=null,listRaw=null;
          for(var u=0;u<listUrls.length;u++){
            try{
              var resp=await fetch(listUrls[u],{credentials:"include",headers:ghdrFor()});
              clientDiag.push("list "+listUrls[u].split("?")[0]+" → "+resp.status);
              if(resp.ok){
                // Peek at the body — empty {} means we got a 200 but
                // no data; keep trying alternate URLs.
                var peekText=await resp.clone().text().catch(function(){return "";});
                if(peekText&&peekText!=="{}"&&peekText!=="[]"){rl=resp;break;}
                clientDiag.push("  empty body, trying next");
              }
              if(u===listUrls.length-1){
                listRaw=await resp.text().catch(function(){return "";});
              }
            }catch(le){clientDiag.push("list err "+u+": "+le.message);}
          }
          if(rl){
            var list=await rl.json();
            var arr=Array.isArray(list)?list:(list&&(list.activityList||list.activities||list.results||list.payload||list.data));
            if(!Array.isArray(arr)){
              var keys=list&&typeof list==="object"?Object.keys(list).slice(0,10):[];
              clientDiag.push("list shape: keys=["+keys.join(",")+"]");
              if(keys.length===0&&list&&typeof list==="object"){
                clientDiag.push("→ Garmin returned empty {} — visit connect.garmin.com/app/activities first, wait for it to load, then sync again.");
              }
              if(list&&typeof list==="object"){
                for(var k=0;k<keys.length;k++){
                  if(Array.isArray(list[keys[k]])){
                    arr=list[keys[k]];
                    clientDiag.push("found array under key '"+keys[k]+"' ("+arr.length+" entries)");
                    break;
                  }
                }
              }
              if(!Array.isArray(arr)){
                var sample=JSON.stringify(list).slice(0,200);
                clientDiag.push("body sample: "+sample);
                arr=[];
              }
            }
            clientDiag.push("list parsed: "+arr.length+" entries"+(Array.isArray(list)?" (array)":" (obj)"));
            var runs=(arr||[]).filter(function(a){
              var t=((a.activityType&&a.activityType.typeKey)||"").toLowerCase();
              return t==="running"||t==="trail_running"||t==="treadmill_running"||
                     t==="indoor_running"||t==="virtual_run"||t==="track_running"||
                     t==="street_running"||t.indexOf("run")>=0;
            }).slice(0,30);
            clientDiag.push("runs after filter: "+runs.length);
            var detailFails=0,dynFetched=0;
            for(var i=0;i<runs.length;i++){
              btn.textContent="Activity "+(i+1)+"/"+runs.length+"...";
              try{
                // Try /gc-api/ first (matches the SPA), fall back to /proxy/.
                var rd=await fetch("/gc-api/activity-service/activity/"+runs[i].activityId,{credentials:"include",headers:ghdrFor()});
                if(!rd.ok){
                  rd=await fetch("/proxy/activity-service/activity/"+runs[i].activityId,{credentials:"include",headers:ghdrFor()});
                }
                if(rd.ok){
                  var detail=await rd.json();
                  // Per-second running-dynamics metrics — the activity
                  // /details payload is the only source of GCT/VO/VR/
                  // stride/power timeseries; feeds the 0.25-mile block
                  // charts on the Form tab.
                  try{
                    var dq="/details?maxChartSize=300&maxPolylineSize=0";
                    var dmr=await fetch("/gc-api/activity-service/activity/"+runs[i].activityId+dq,{credentials:"include",headers:ghdrFor()});
                    if(!dmr.ok){dmr=await fetch("/proxy/activity-service/activity/"+runs[i].activityId+dq,{credentials:"include",headers:ghdrFor()});}
                    if(dmr.ok){
                      var dmj=await dmr.json();
                      detail.crDetailMetrics={metricDescriptors:dmj.metricDescriptors||null,activityDetailMetrics:dmj.activityDetailMetrics||null};
                      if(dmj.activityDetailMetrics&&dmj.activityDetailMetrics.length)dynFetched++;
                    }
                  }catch(de){}
                  activities.push(detail);
                }else{
                  detailFails++;
                  if(detailFails<=3) clientDiag.push("act "+runs[i].activityId+" → "+rd.status);
                }
              }catch(e){detailFails++;if(detailFails<=3) clientDiag.push("act err: "+e.message);}
              await new Promise(function(r){setTimeout(r,200)});
            }
            if(detailFails>3) clientDiag.push("(+"+(detailFails-3)+" more detail failures)");
            clientDiag.push("dynamics details fetched: "+dynFetched+"/"+runs.length);
          }else if(listRaw){
            clientDiag.push("list body sample: "+listRaw.slice(0,140).replace(/\\s+/g," "));
          }
        }catch(e){clientDiag.push("activities err: "+e.message);console.warn("[chris.run] activities fetch failed:",e.message)}
        // Endpoint inventory — every metrics/weight URL the SPA or this
        // sync has called this session. Surfaces the real Garmin endpoints
        // so a mis-guessed URL can be corrected from the result alert.
        try{
          var seenUrls=Object.keys(window.__crLastGarminHeaders||{})
            .filter(function(k){return /metrics-service|weight-service|maxmet|endurance|hill/i.test(k);})
            .map(function(k){return k.replace(/^https?:\\/\\/[^/]+/,"");});
          if(seenUrls.length)clientDiag.push("seen: "+seenUrls.slice(0,14).join(" "));
        }catch(e){}
        btn.textContent="Saving...";
        var cookies=document.cookie;
        var resp=await fetch(garminEndpoint,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({uid:uid,key:garminKey,data:{dates:dates,weight:weight,performance:performance,activities:activities,cookies:cookies}})});
        var result=await resp.json();
        if(result.success){
          btn.textContent="✓ "+result.healthDays+"d · "+(result.activitiesEnriched||0)+" runs";btn.style.background="#059669";
          var diagText="";
          if(result.activitiesAttempted!=null){
            diagText="\\n\\nActivities sent: "+result.activitiesAttempted+" / enriched: "+(result.activitiesEnriched||0);
            if(result.dynamicsStreamsStored!=null){diagText+="\\nDynamics streams stored: "+result.dynamicsStreamsStored;}
            if(result.detailKeys&&result.detailKeys.length){diagText+="\\nDetail metric keys: "+result.detailKeys.join(", ");}
            if(result.matchDiag&&result.matchDiag.length){
              diagText+="\\n\\nMatch detail (first "+result.matchDiag.length+"):";
              result.matchDiag.forEach(function(d){
                if(d.matched){diagText+="\\n✓ "+d.gid+" → "+d.type+" ("+d.deltaSec+"s drift)";}
                else{diagText+="\\n✗ "+d.gid+": "+(d.reason||"unknown")+(d.gmt?" [gmt="+d.gmt+"]":"");}
              });
            }
          }
          if(clientDiag.length){
            diagText+="\\n\\nClient fetch diag:";
            clientDiag.forEach(function(s){diagText+="\\n  "+s;});
          }
          // Surface the VO2max-history capture count so users can
          // confirm the chart-data interception worked (and re-open
          // the VO₂ Max chart if it didn't).
          var _mmN = (function(){
            var mm = performance && performance.maxMetHistory;
            if (!mm) return 0;
            if (Array.isArray(mm)) return mm.length;
            if (Array.isArray(mm.generic)) return mm.generic.length;
            if (Array.isArray(mm.maxMetCategories)) return mm.maxMetCategories.length;
            if (Array.isArray(mm.metricDescriptors)) return mm.metricDescriptors.length;
            return (typeof mm === "object" && Object.keys(mm).length) ? 1 : 0;
          })();
          diagText += "\\n\\nVO2max history captured: " + _mmN + " day(s)" + (_mmN === 0 ? " — open the Garmin Connect VO₂ Max chart, then click Sync again." : "");
          var _esN = _crScoreLen(window.__crEnduranceData), _hsN = _crScoreLen(window.__crHillData);
          diagText += "\\nEndurance/Hill captured: Endurance " + _esN + " · Hill " + _hsN + ((_esN === 0 && _hsN === 0) ? " — open the Endurance Score & Hill Score charts in Garmin Connect, then Sync again." : "");
          alert("Body comp:\\n"+(result.bodyCompSummary||"(none)")+"\\nRuns enriched: "+(result.activitiesEnriched||0)+diagText)
        }else{btn.textContent="Error: "+(result.error||"unknown");btn.style.background="#dc2626"}
      }catch(e){btn.textContent="Error: "+e.message;btn.style.background="#dc2626"}
      setTimeout(function(){btn.textContent=_crIdleLabel();btn.style.background="#06b6d4"},5000);
    };
    btn.onclick = doGarminSync;
    document.body.appendChild(btn);

    // Zero-touch auto-sync: run once per calendar day when you open
    // Garmin Connect, throttled via localStorage so navigating around the
    // SPA doesn't re-fire it. The userscript can only run while the site
    // is open in this browser, so this fires "daily" only if you visit —
    // the button stays for an on-demand pull any time. Delay a few seconds
    // to let the SPA settle (and any maxmet/weight passive capture land).
    try {
      var _crTodayKey = new Date().toISOString().split("T")[0];
      if (localStorage.getItem("crGarminLastAutoSync") !== _crTodayKey) {
        setTimeout(function(){
          try { localStorage.setItem("crGarminLastAutoSync", _crTodayKey); } catch(e){}
          doGarminSync();
        }, 4000);
      }
    } catch(e){}

    // VO2max capture badge — peek at window.__crMaxMetData every 2s
    // while the button is idle ("⚡ Sync to chris.run") so the user
    // sees at a glance whether opening the VO₂ Max chart actually
    // intercepted the SPA's data fetch. No badge → re-open the chart
    // before clicking sync.
    function _crCountMaxMet() {
      var mm = window.__crMaxMetData;
      if (!mm) return 0;
      if (Array.isArray(mm)) return mm.length;
      if (Array.isArray(mm.generic)) return mm.generic.length;
      if (Array.isArray(mm.maxMetCategories)) return mm.maxMetCategories.length;
      if (Array.isArray(mm.metricDescriptors)) return mm.metricDescriptors.length;
      return (typeof mm === "object" && Object.keys(mm).length) ? 1 : 0;
    }
    function _crIdleLabel() {
      var parts = [];
      var n = _crCountMaxMet();
      if (n > 0) parts.push("VO2 " + n + "d");
      var es = _crScoreLen(window.__crEnduranceData);
      if (es > 0) parts.push("Endur " + es);
      var hs = _crScoreLen(window.__crHillData);
      if (hs > 0) parts.push("Hill " + hs);
      return parts.length ? ("⚡ Sync to chris.run · " + parts.join(" · ")) : "⚡ Sync to chris.run";
    }
    btn.textContent = _crIdleLabel();
    setInterval(function() {
      // Only refresh when the button is idle — don't clobber in-flight
      // sync status messages like "Fetching 3/7..." or success/error
      // labels.
      if (btn.textContent.indexOf("⚡ Sync") !== 0) return;
      btn.textContent = _crIdleLabel();
    }, 2000);
    } // end else garminKey
  }
})();`;
                  const blob = new Blob([script], { type: "text/javascript" });
                  const url = URL.createObjectURL(blob);
                  const a = document.createElement("a");
                  a.href = url;
                  a.download = "chris-run-sync.user.js";
                  a.click();
                  URL.revokeObjectURL(url);
                  setSuccess("Userscript downloaded! Open it with Tampermonkey to install.");
                }} style={{
                  background: C.cyan, color: "#000", border: "none", borderRadius: 8,
                  padding: "10px 20px", fontSize: 13, fontWeight: 600, cursor: "pointer", whiteSpace: "nowrap",
                }}>
                  Download Userscript
                </button>
              </div>
              <div style={{ marginTop: 10, fontSize: 11, color: C.textMuted, lineHeight: 1.6 }}>
                Requires <a href="https://www.tampermonkey.net/" target="_blank" rel="noopener" style={{ color: C.cyan }}>Tampermonkey</a> browser extension (free).
                Install Tampermonkey first, then click Download — it will prompt to install the script.
                After that, a floating "Sync" button appears on MFP and Garmin pages automatically.
              </div>
            </Card>
          )}

          <h3 style={{ fontSize: 14, fontWeight: 700, color: C.textDim, marginBottom: 12, textTransform: "uppercase", letterSpacing: "0.05em" }}>Data Sources</h3>

          <ConnectionCard
            icon="🏃"
            name="Strava"
            description="Activities, routes, pace, heart rate, performance data + shoe gear catalog"
            status={stravaStatus ? { ...stravaStatus, lastSync: stravaLastSync } : stravaStatus}
            onSync={runStravaSync}
            syncingThis={syncing === "strava"}
            color="#FC4C02"
          />
          {stravaSyncMsg && (
            <div style={{ marginTop: -10, marginBottom: 14, padding: "8px 14px", background: stravaSyncMsg.type === "err" ? C.red + "10" : C.green + "10", border: `1px solid ${stravaSyncMsg.type === "err" ? C.red : C.green}40`, borderRadius: 8, fontSize: 11, color: stravaSyncMsg.type === "err" ? C.red : C.green, fontFamily: "'IBM Plex Mono',monospace" }}>
              {stravaSyncMsg.text}
            </div>
          )}

          {/* Detailed-streams sync toggle — opt-in. When enabled, the
              next syncStravaActivities run will fetch laps + per-second
              streams (HR, pace, cadence, altitude, grade) for runs in
              the chosen window. Costs 2 extra Strava API calls per
              activity; ~50 activities per 15min rate-limit window. */}
          {stravaStatus && stravaStatus !== false && (
            <Card style={{ marginBottom: 16 }}>
              <div style={{ display: "flex", alignItems: "center", gap: 12, flexWrap: "wrap" }}>
                <input type="checkbox" checked={!!stravaStreamSettings.fetchStreams}
                  onChange={(e) => setStravaStreamSettings({ ...stravaStreamSettings, fetchStreams: e.target.checked })}
                  style={{ width: 18, height: 18, accentColor: C.cyan, flexShrink: 0 }} />
                <div style={{ flex: 1, minWidth: 200 }}>
                  <div style={{ fontSize: 13, fontWeight: 700, color: C.text }}>Sync detailed streams (per-second HR / pace / cadence / altitude)</div>
                  <div style={{ fontSize: 11, color: C.textMuted, marginTop: 2, lineHeight: 1.5 }}>
                    Unlocks lap-level workout grading + per-mile pacing analysis. Costs 2 Strava API calls per activity — kept inside Strava's rate limit by capping at recent activities only.
                  </div>
                </div>
                <select value={stravaStreamSettings.streamsWindowDays || 60}
                  onChange={(e) => setStravaStreamSettings({ ...stravaStreamSettings, streamsWindowDays: Number(e.target.value) })}
                  disabled={!stravaStreamSettings.fetchStreams}
                  style={{ padding: "6px 10px", borderRadius: 6, border: `1px solid ${C.border}`, background: C.bg2, color: C.text, fontSize: 12, fontFamily: "'IBM Plex Mono',monospace" }}>
                  <option value={30}>last 30 days</option>
                  <option value={60}>last 60 days</option>
                  <option value={90}>last 90 days</option>
                  <option value={180}>last 180 days</option>
                </select>
                <button onClick={saveStravaStreamSettings} disabled={savingStravaStreams} style={{
                  padding: "6px 14px", borderRadius: 6, border: `1px solid ${C.cyan}`,
                  background: savingStravaStreams ? C.bg2 : C.cyan, color: savingStravaStreams ? C.textMuted : "#000",
                  fontSize: 11, fontWeight: 700, cursor: savingStravaStreams ? "not-allowed" : "pointer", fontFamily: "'IBM Plex Mono',monospace",
                }}>{savingStravaStreams ? "..." : streamsSettingsSaved ? "Saved ✓" : "Save"}</button>
              </div>
            </Card>
          )}


          {/* Garmin Connect — unified card (popup sync) */}
          <Card style={{ marginBottom: 16 }}>
            <div style={{ display: "flex", alignItems: "center", gap: 16 }}>
              <div style={{ fontSize: 32, width: 48, textAlign: "center" }}>{"⌚"}</div>
              <div style={{ flex: 1 }}>
                <div style={{ fontWeight: 700, fontSize: 15, color: C.text, marginBottom: 4 }}>Garmin Connect</div>
                <div style={{ fontSize: 12, color: C.textMuted, lineHeight: 1.5 }}>
                  Body Battery, HRV, stress, SpO2, training readiness, sleep, respiration, body composition.
                  Syncs automatically twice daily.
                </div>
                {scraperStatus && (
                  <div style={{ marginTop: 6, fontSize: 11 }}>
                    {scraperStatus.hasCookies && !(scraperStatus.expiresApprox && scraperStatus.expiresApprox < new Date()) ? (
                      <span style={{ color: C.green, fontWeight: 600 }}>Connected</span>
                    ) : scraperStatus.hasCookies ? (
                      <span style={{ color: C.red, fontWeight: 600 }}>Session expired</span>
                    ) : (
                      <span style={{ color: C.red, fontWeight: 600 }}>Not connected</span>
                    )}
                    {scraperStatus.updatedAt && (
                      <span style={{ color: C.textMuted }}> · Last refreshed: {scraperStatus.updatedAt.toLocaleString()}</span>
                    )}
                    {scraperStatus.expiresApprox && scraperStatus.hasCookies && (
                      <span style={{ color: scraperStatus.expiresApprox < new Date() ? C.red : C.textMuted }}>
                        {" "}· Expires ~{scraperStatus.expiresApprox.toLocaleDateString()}
                      </span>
                    )}
                  </div>
                )}
              </div>
              {garminSyncKey && (
                <button onClick={startGarminPopupSync} style={{
                  background: C.cyan, color: "#000", border: "none", borderRadius: 8,
                  padding: "10px 18px", fontSize: 12, fontWeight: 700, cursor: "pointer",
                  fontFamily: "'IBM Plex Mono',monospace", flexShrink: 0,
                }}>
                  Sync Now
                </button>
              )}
            </div>
            <div style={{ fontSize: 11, color: C.textMuted, marginTop: 10, padding: "6px 10px", background: C.bg2, borderRadius: 6, lineHeight: 1.5 }}>
              Opens Garmin Connect in a new tab and fetches the past 7 days of health data directly from your account.
            </div>
            <div style={{ marginTop: 12, display: "flex", alignItems: "center", gap: 12, flexWrap: "wrap" }}>
              <button
                disabled={typeof bbBackfill === "string" && bbBackfill.startsWith("running")}
                onClick={runBodyBatteryBackfill}
                style={{ padding: "8px 16px", borderRadius: 6, border: `1px solid ${C.cyan}55`,
                  cursor: (typeof bbBackfill === "string" && bbBackfill.startsWith("running")) ? "default" : "pointer",
                  background: C.cyan + "15", color: C.cyan, fontSize: 12, fontFamily: "'IBM Plex Mono',monospace",
                  opacity: (typeof bbBackfill === "string" && bbBackfill.startsWith("running")) ? 0.6 : 1 }}>
                {bbBackfill === "running" ? "Backfilling…"
                  : (typeof bbBackfill === "string" && bbBackfill.startsWith("running:~")) ? `Backfilling… ${bbBackfill.split("~")[1]}`
                  : "Backfill Body Battery (6 mo)"}
              </button>
              {bbBackfill && !bbBackfill.startsWith("running") && (
                <span style={{ fontSize: 11, color: C.textMuted }}>{bbBackfill}</span>
              )}
            </div>
            <div style={{ fontSize: 11, color: C.textMuted, marginTop: 8, padding: "6px 10px", background: C.bg2, borderRadius: 6, lineHeight: 1.5 }}>
              Pulls ~6 months of Body Battery, sleep, HRV & stress from Garmin's API using your saved OAuth session, in resumable chunks. Note this server session is the fragile path — it can hit Garmin rate limits or need MFA re-auth. If it can't complete, use <strong style={{ color: C.text }}>Historical Backfill</strong> below to import a Garmin "Export Your Data" ZIP — that's the reliable route for deep gaps.
            </div>

            {/* Garmin OAuth (workout push) — separate from the bookmarklet sync above.
                Used by pushWorkoutToGarmin / pushWeekToGarmin. Tokens expire ~30
                days; this is the only UI to refresh them. Without a valid
                session here, the ⌚ buttons in the Plan tab fail with
                "No valid Garmin session — re-authenticate via the app". */}
            <div style={{ marginTop: 14, padding: "12px 14px", background: C.bg2, borderRadius: 8, border: `1px solid ${C.border}` }}>
              <div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 12, flexWrap: "wrap" }}>
                <div style={{ flex: 1, minWidth: 200 }}>
                  <div style={{ fontSize: 13, fontWeight: 700, color: C.text, marginBottom: 2 }}>
                    Workout Push (OAuth Session)
                  </div>
                  <div style={{ fontSize: 11, color: C.textMuted, lineHeight: 1.5 }}>
                    Required for the ⌚ Push buttons in the Plan tab. Garmin emails a 6-digit MFA code; tokens then last ~30 days.
                  </div>
                </div>
                {garminOauthStep === "idle" && (
                  <button onClick={garminSendMFACode} style={{
                    background: C.cyan, color: "#000", border: "none", borderRadius: 6,
                    padding: "8px 14px", fontSize: 11, fontWeight: 700, cursor: "pointer",
                    fontFamily: "'IBM Plex Mono',monospace", flexShrink: 0,
                  }}>Send MFA Code</button>
                )}
                {garminOauthStep === "sending" && (
                  <span style={{ fontSize: 11, color: C.textMuted, fontFamily: "'IBM Plex Mono',monospace" }}>Sending…</span>
                )}
                {garminOauthStep === "done" && (
                  <button onClick={() => { setGarminOauthStep("idle"); setGarminOauthMsg(null); }} style={{
                    background: "none", color: C.textMuted, border: `1px solid ${C.border}`, borderRadius: 6,
                    padding: "6px 12px", fontSize: 11, cursor: "pointer", fontFamily: "'IBM Plex Mono',monospace",
                  }}>Reset</button>
                )}
              </div>
              {(garminOauthStep === "code-sent" || garminOauthStep === "verifying") && (
                <div style={{ display: "flex", gap: 8, alignItems: "center", marginTop: 10, flexWrap: "wrap" }}>
                  <input
                    type="text"
                    inputMode="numeric"
                    autoComplete="one-time-code"
                    placeholder="6-digit code"
                    value={garminOauthCode}
                    onChange={e => setGarminOauthCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
                    onKeyDown={e => { if (e.key === "Enter" && garminOauthCode.length === 6) garminVerifyMFACode(); }}
                    autoFocus
                    style={{
                      flex: 1, minWidth: 120, padding: "8px 12px", borderRadius: 6,
                      border: `1px solid ${C.border}`, background: C.bg, color: C.text,
                      fontSize: 14, fontFamily: "'IBM Plex Mono',monospace", letterSpacing: "0.15em", textAlign: "center",
                    }}
                  />
                  <button
                    onClick={garminVerifyMFACode}
                    disabled={garminOauthStep === "verifying" || garminOauthCode.length !== 6}
                    style={{
                      background: garminOauthCode.length === 6 ? C.cyan : C.bg3, color: garminOauthCode.length === 6 ? "#000" : C.textMuted,
                      border: "none", borderRadius: 6, padding: "8px 16px", fontSize: 11, fontWeight: 700,
                      cursor: garminOauthCode.length === 6 ? "pointer" : "not-allowed",
                      fontFamily: "'IBM Plex Mono',monospace",
                    }}>
                    {garminOauthStep === "verifying" ? "Verifying…" : "Verify"}
                  </button>
                  <button onClick={garminSendMFACode} disabled={garminOauthStep === "verifying"} style={{
                    background: "none", color: C.textMuted, border: `1px solid ${C.border}`, borderRadius: 6,
                    padding: "8px 12px", fontSize: 11, cursor: "pointer", fontFamily: "'IBM Plex Mono',monospace",
                  }}>Resend</button>
                </div>
              )}
              {garminOauthMsg && (
                <div style={{
                  marginTop: 10, padding: "8px 12px", borderRadius: 6, fontSize: 11,
                  background: garminOauthMsg.type === "ok" ? C.green + "15" : C.red + "15",
                  color: garminOauthMsg.type === "ok" ? C.green : C.red,
                  border: `1px solid ${garminOauthMsg.type === "ok" ? C.green : C.red}40`,
                }}>
                  {garminOauthMsg.text}
                  {/* If the error mentions rate-limiting, offer an inline
                      "Clear" button that wipes our local 120-min cache —
                      Garmin's actual block is often shorter (15-60 min). */}
                  {garminOauthMsg.type === "err" && /rate.?limit/i.test(garminOauthMsg.text) && (
                    <div style={{ marginTop: 8, display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}>
                      <button onClick={garminClearRateLimit} disabled={garminClearing} style={{
                        background: "none", border: `1px solid ${C.red}60`, color: C.red,
                        borderRadius: 4, padding: "4px 10px", cursor: garminClearing ? "wait" : "pointer",
                        fontSize: 10, fontFamily: "'IBM Plex Mono',monospace",
                      }}>{garminClearing ? "Clearing…" : "Clear local rate-limit"}</button>
                      <span style={{ fontSize: 10, color: C.textMuted }}>
                        Our cache is 120 min; Garmin's actual limit is often shorter. Clear to retry sooner — if Garmin still blocks, the 120-min clock restarts.
                      </span>
                    </div>
                  )}
                </div>
              )}
            </div>

            {/* Last sync result debug panel */}
            {lastGarminSyncResult && (
              <div style={{ marginTop: 10, padding: "10px 12px", background: C.bg2, borderRadius: 8, fontSize: 11, fontFamily: "'IBM Plex Mono',monospace" }}>
                <div style={{ fontWeight: 700, color: C.text, marginBottom: 6, fontSize: 12 }}>
                  Last sync — {lastGarminSyncResult.syncedAt.toLocaleTimeString()}
                  {" "}<span style={{ color: lastGarminSyncResult.healthDays > 0 ? C.green : C.red }}>
                    {lastGarminSyncResult.healthDays} health days
                  </span>
                  {lastGarminSyncResult.activitiesEnriched > 0 && <span style={{ color: C.cyan }}> · {lastGarminSyncResult.activitiesEnriched} activities enriched</span>}
                  {lastGarminSyncResult.perfStats && <span style={{ color: "#a78bfa" }}> · perf stats ✓</span>}
                </div>
                {lastGarminSyncResult.daysSummary && Object.keys(lastGarminSyncResult.daysSummary).length > 0 ? (
                  <div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
                    {Object.entries(lastGarminSyncResult.daysSummary).sort(([a],[b]) => b.localeCompare(a)).map(([date, fields]) => (
                      <div key={date} style={{ display: "flex", gap: 8, alignItems: "baseline" }}>
                        <span style={{ color: C.textMuted, minWidth: 85 }}>{date}</span>
                        <span style={{ color: fields.length > 0 ? C.green : C.red }}>
                          {fields.length > 0 ? fields.join(", ") : "no fields saved"}
                        </span>
                      </div>
                    ))}
                  </div>
                ) : (
                  <div style={{ color: C.red }}>No health data written — server received empty days</div>
                )}
                {lastGarminSyncResult.bodyCompSummary && (
                  <div style={{ marginTop: 6, color: C.textMuted, wordBreak: "break-all" }}>
                    Body comp: {lastGarminSyncResult.bodyCompSummary}
                  </div>
                )}
              </div>
            )}

            <div style={{ marginTop: 10, padding: "6px 10px", background: C.bg2, borderRadius: 6, fontSize: 11, color: C.textMuted, lineHeight: 1.6 }}>
              Syncs automatically at 06:30 and 18:30 UTC via GitHub Actions (Python scraper).
              To trigger manually: <a href="https://github.com/ccooke1981/chris.run/actions/workflows/garmin_sync.yml" target="_blank" rel="noopener" style={{ color: C.cyan }}>GitHub Actions → Garmin Daily Sync → Run workflow</a>
            </div>

            {/* Firestore health data check */}
            <div style={{ marginTop: 8 }}>
              <button onClick={async () => {
                if (healthDiagLoading) return;
                setHealthDiagLoading(true);
                try {
                  // Fetch last 7 dates directly (avoids requiring a Firestore index)
                  const dates = Array.from({length: 7}, (_, i) => {
                    const d = new Date(); d.setDate(d.getDate() - i);
                    return d.toISOString().slice(0, 10);
                  });
                  const snaps = await Promise.all(dates.map(date =>
                    db.collection("users").doc(userId).collection("health").doc(date).get()
                  ));
                  const docs = snaps.filter(d => d.exists).map(d => {
                    const data = d.data();
                    const fields = Object.keys(data).filter(k => !["source","syncedAt","date","lastGarminSync","sources"].includes(k));
                    return { date: d.id, source: data.source || (data.sources ? data.sources.join(",") : "?"), fields };
                  });
                  setHealthDiag(docs.length > 0 ? docs : []);
                } catch(e) { setHealthDiag([{ date: "error", source: "", fields: [e.message] }]); }
                setHealthDiagLoading(false);
              }} style={{ background: "none", border: "none", cursor: "pointer", fontSize: 11, color: C.textMuted, padding: 0 }}>
                {healthDiagLoading ? "Loading..." : "▸ Check Firestore health data"}
              </button>
              {healthDiag && (
                <div style={{ marginTop: 4, padding: "8px 10px", background: C.bg2, borderRadius: 6, fontSize: 11, fontFamily: "'IBM Plex Mono',monospace" }}>
                  {healthDiag.length === 0 ? (
                    <span style={{ color: C.red }}>No health documents in Firestore</span>
                  ) : healthDiag.map(doc => (
                    <div key={doc.date} style={{ marginBottom: 3 }}>
                      <span style={{ color: C.textMuted, display: "inline-block", minWidth: 88 }}>{doc.date}</span>
                      <span style={{ color: C.amber }}>[{doc.source}]</span>
                      {" "}<span style={{ color: doc.fields.length > 2 ? C.green : C.red }}>
                        {doc.fields.length > 0 ? doc.fields.join(", ") : "empty"}
                      </span>
                    </div>
                  ))}
                </div>
              )}
            </div>

            {/* Scraper payload viewer — owner only (requires auth to read config/) */}
            {isOwner && <div style={{ marginTop: 8 }}>
              <button onClick={async () => {
                const opening = !scraperDebugOpen;
                setScraperDebugOpen(opening);
                if (opening && !scraperDebug) {
                  try {
                    const doc = await db.doc("config/garmin_scraper_debug").get();
                    setScraperDebug(doc.exists ? doc.data() : { empty: true });
                  } catch(e) { setScraperDebug({ error: e.message }); }
                }
              }} style={{ background: "none", border: "none", cursor: "pointer", fontSize: 11, color: C.textMuted, padding: 0 }}>
                {scraperDebugOpen ? "▾" : "▸"} Last scraper payload
              </button>
              {scraperDebugOpen && scraperDebug && (
                <div style={{ marginTop: 6, padding: "10px 12px", background: C.bg2, borderRadius: 8, fontSize: 11 }}>
                  {scraperDebug.empty ? (
                    <div style={{ color: C.red }}>No scraper payload saved yet — run the scraper once to populate this.</div>
                  ) : scraperDebug.error ? (
                    <div style={{ color: C.red }}>{scraperDebug.error}</div>
                  ) : (<>
                    <div style={{ color: C.textMuted, marginBottom: 8 }}>
                      Scraped: {scraperDebug.scrapedAt?.toDate ? scraperDebug.scrapedAt.toDate().toLocaleString() : "unknown"}
                    </div>
                    {/* Key summary — click to inspect */}
                    <div style={{ display: "flex", flexWrap: "wrap", gap: 6, marginBottom: 10 }}>
                      {Object.entries(scraperDebug.summary || {}).map(([k, count]) => (
                        <button key={k} onClick={() => setScraperDebugKey(scraperDebugKey === k ? null : k)} style={{
                          background: scraperDebugKey === k ? C.cyan + "30" : C.bg,
                          border: `1px solid ${scraperDebugKey === k ? C.cyan : C.border}`,
                          borderRadius: 6, padding: "3px 8px", fontSize: 10, cursor: "pointer",
                          color: count > 0 ? C.green : C.red, fontFamily: "'IBM Plex Mono',monospace",
                        }}>
                          {k} ({count})
                        </button>
                      ))}
                    </div>
                    {/* Health dates extracted */}
                    {scraperDebug.healthDates && Object.keys(scraperDebug.healthDates).length > 0 && (
                      <div style={{ marginBottom: 10 }}>
                        <div style={{ color: C.textMuted, marginBottom: 4, fontSize: 10 }}>Fields extracted per date:</div>
                        {Object.entries(scraperDebug.healthDates).sort(([a],[b]) => b.localeCompare(a)).map(([date, keys]) => (
                          <div key={date} style={{ fontFamily: "'IBM Plex Mono',monospace", marginBottom: 2 }}>
                            <span style={{ color: C.textMuted, display: "inline-block", minWidth: 88 }}>{date}</span>
                            <span style={{ color: keys.length > 0 ? C.green : C.red }}>{keys.join(", ") || "none"}</span>
                          </div>
                        ))}
                      </div>
                    )}
                    {/* Sample viewer for selected key */}
                    {scraperDebugKey && scraperDebug.samples?.[scraperDebugKey] && (
                      <div>
                        <input
                          value={scraperDebugSearch}
                          onChange={e => setScraperDebugSearch(e.target.value)}
                          placeholder={`Search in ${scraperDebugKey} sample...`}
                          style={{ width: "100%", padding: "5px 8px", borderRadius: 4, border: `1px solid ${C.border}`,
                            background: C.bg, color: C.text, fontSize: 11, fontFamily: "'IBM Plex Mono',monospace",
                            marginBottom: 6, boxSizing: "border-box" }}
                        />
                        <pre style={{ margin: 0, whiteSpace: "pre-wrap", wordBreak: "break-all", color: C.text,
                          maxHeight: 300, overflow: "auto", fontSize: 10, lineHeight: 1.5 }}>
                          {(() => {
                            const raw = scraperDebug.samples[scraperDebugKey];
                            if (!scraperDebugSearch) return raw;
                            const search = scraperDebugSearch.toLowerCase();
                            return raw.split("\n").filter(l => l.toLowerCase().includes(search)).join("\n") || "(no matches)";
                          })()}
                        </pre>
                      </div>
                    )}
                  </>)}
                </div>
              )}
            </div>}

            {/* Historical Backfill — Garmin Export File Import */}
            <div style={{ marginTop: 12 }}>
              <button onClick={() => setBackfillOpen(!backfillOpen)} style={{
                background: "none", border: "none", cursor: "pointer", fontSize: 11, color: C.textMuted, padding: 0,
              }}>
                {backfillOpen ? "▾" : "▸"} Historical Backfill
              </button>
              {backfillOpen && (() => {
                const importGarminZip = async (file) => {
                  setBackfilling(true);
                  setBackfillProgress({ status: "reading", message: "Reading ZIP file..." });
                  setError(null);
                  try {
                    // Lazy-load JSZip on first use (avoids blocking initial page load)
                    if (typeof JSZip === "undefined") {
                      await new Promise((resolve, reject) => {
                        const s = document.createElement("script");
                        s.src = "https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js";
                        s.onload = resolve; s.onerror = reject;
                        document.head.appendChild(s);
                      });
                    }
                    const zip = await JSZip.loadAsync(file);
                    const stats = { healthDays: 0, sleepDays: 0, activities: 0, skipped: 0, errors: 0 };

                    // Find relevant JSON files inside the ZIP
                    const files = Object.keys(zip.files);
                    const wellnessFiles = files.filter(f => /UDSFile.*\.json$/i.test(f));
                    const sleepFiles = files.filter(f => /sleepData\.json$/i.test(f));
                    const activityFiles = files.filter(f => /summarizedActivities\.json$/i.test(f));

                    setBackfillProgress({ status: "parsing", message: `Found ${wellnessFiles.length} wellness, ${sleepFiles.length} sleep, ${activityFiles.length} activity files` });

                    // ── Parse & write wellness (UDSFile) ────────────────────────
                    for (const fname of wellnessFiles) {
                      try {
                        const raw = await zip.files[fname].async("string");
                        const data = JSON.parse(raw);
                        const entries = Array.isArray(data) ? data : (data.days || data.dailySummaries || [data]);
                        for (const day of entries) {
                          const dateStr = day.calendarDate;
                          if (!dateStr || !/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) continue;
                          const healthDoc = {};
                          if (day.restingHeartRate) healthDoc.restingHR = day.restingHeartRate;
                          if (day.minHeartRate) healthDoc.minHR = day.minHeartRate;
                          if (day.maxHeartRate) healthDoc.maxHR = day.maxHeartRate;
                          if (day.totalSteps) healthDoc.steps = day.totalSteps;
                          if (day.totalKilocalories) healthDoc.totalKilocalories = Math.round(day.totalKilocalories);
                          if (day.activeKilocalories) healthDoc.activeKilocalories = Math.round(day.activeKilocalories);
                          if (day.bmrKilocalories) healthDoc.bmrKilocalories = Math.round(day.bmrKilocalories);
                          if (day.floorsAscended != null) healthDoc.floorsAscended = Math.round(day.floorsAscended);
                          if (day.averageStressLevel) healthDoc.stress = day.averageStressLevel;
                          if (day.maxStressLevel) healthDoc.stressMax = day.maxStressLevel;
                          if (day.moderateIntensityMinutes != null || day.vigorousIntensityMinutes != null) {
                            healthDoc.intensityMinutes = (day.moderateIntensityMinutes || 0) + (day.vigorousIntensityMinutes || 0) * 2;
                          }
                          // Body Battery — the UDS daily summary carries the day's
                          // high/low levels and charged/drained deltas (the export
                          // has no intraday curve). Map to the same canonical fields
                          // the live sync writes so the Today card and the Body
                          // Battery chart populate. Primary value is the overnight
                          // high, matching mapHealthApiBodyBattery's contract.
                          const bbStart = day.bodyBatteryAtWakeTime ?? null;
                          const bbHigh = day.bodyBatteryHighestValue ?? day.bodyBatteryHigh ?? day.highestBodyBatteryValue ?? null;
                          const bbLow = day.bodyBatteryLowestValue ?? day.bodyBatteryLow ?? day.lowestBodyBatteryValue ?? null;
                          const bbPrimary = bbStart ?? bbHigh;
                          if (bbPrimary != null) healthDoc.bodyBattery = bbPrimary;
                          if (bbStart != null) healthDoc.bodyBatteryStartOfDay = bbStart;
                          if (bbHigh != null) healthDoc.bodyBatteryMax = bbHigh;
                          if (bbLow != null) healthDoc.bodyBatteryMin = bbLow;
                          if (day.bodyBatteryMostRecentValue != null) healthDoc.bodyBatteryEndOfDay = day.bodyBatteryMostRecentValue;
                          if (day.bodyBatteryChargedValue != null) healthDoc.bodyBatteryCharged = day.bodyBatteryChargedValue;
                          if (day.bodyBatteryDrainedValue != null) healthDoc.bodyBatteryDrained = day.bodyBatteryDrainedValue;
                          if (Object.keys(healthDoc).length === 0) continue;
                          healthDoc.source = "garmin-export";
                          healthDoc.importedAt = firebase.firestore.FieldValue.serverTimestamp();
                          await db.collection("users").doc(userId).collection("health").doc(dateStr)
                            .set(healthDoc, { merge: true });
                          stats.healthDays++;
                        }
                      } catch (e) { stats.errors++; console.warn("Wellness parse error:", fname, e.message); }
                      setBackfillProgress({ status: "importing", message: `Wellness: ${stats.healthDays} days imported...` });
                    }

                    // ── Parse & write sleep data ────────────────────────────────
                    for (const fname of sleepFiles) {
                      try {
                        const raw = await zip.files[fname].async("string");
                        const data = JSON.parse(raw);
                        const entries = Array.isArray(data) ? data : [data];
                        for (const rec of entries) {
                          const dto = rec.dailySleepDTO || rec;
                          const dateStr = dto.calendarDate;
                          if (!dateStr || !/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) continue;
                          const healthDoc = {};
                          const sleepSecs = dto.sleepTimeSeconds || dto.sleepDuration;
                          if (sleepSecs) healthDoc.sleepHours = Math.round((sleepSecs / 3600) * 10) / 10;
                          if (sleepSecs) healthDoc.sleepDurationMs = sleepSecs * 1000;
                          if (dto.deepSleepSeconds) healthDoc.deepSleepMs = dto.deepSleepSeconds * 1000;
                          if (dto.lightSleepSeconds) healthDoc.lightSleepMs = dto.lightSleepSeconds * 1000;
                          if (dto.remSleepSeconds) healthDoc.remSleepMs = dto.remSleepSeconds * 1000;
                          if (dto.awakeSleepSeconds) healthDoc.awakeSleepMs = dto.awakeSleepSeconds * 1000;
                          if (dto.sleepStartTimestampLocal || dto.sleepStartTimestampGMT) healthDoc.sleepStart = dto.sleepStartTimestampLocal || dto.sleepStartTimestampGMT;
                          if (dto.sleepEndTimestampLocal || dto.sleepEndTimestampGMT) healthDoc.sleepEnd = dto.sleepEndTimestampLocal || dto.sleepEndTimestampGMT;
                          const score = rec.sleepScores?.overall?.value ?? rec.sleepScores?.overall ?? dto.overallSleepScore ?? dto.sleepScore;
                          if (score != null) healthDoc.sleepScore = score;
                          if (dto.averageSpO2Value) healthDoc.spo2 = dto.averageSpO2Value;
                          if (dto.lowestSpO2Value) healthDoc.spo2Min = dto.lowestSpO2Value;
                          if (dto.averageRespirationValue) healthDoc.respirationSleep = dto.averageRespirationValue;
                          if (rec.restingHeartRate) healthDoc.restingHR = rec.restingHeartRate;
                          if (Object.keys(healthDoc).length === 0) continue;
                          healthDoc.source = "garmin-export";
                          healthDoc.importedAt = firebase.firestore.FieldValue.serverTimestamp();
                          await db.collection("users").doc(userId).collection("health").doc(dateStr)
                            .set(healthDoc, { merge: true });
                          stats.sleepDays++;
                        }
                      } catch (e) { stats.errors++; console.warn("Sleep parse error:", fname, e.message); }
                      setBackfillProgress({ status: "importing", message: `Sleep: ${stats.sleepDays} days imported...` });
                    }

                    // ── Parse & enrich activities ───────────────────────────────
                    for (const fname of activityFiles) {
                      try {
                        const raw = await zip.files[fname].async("string");
                        const data = JSON.parse(raw);
                        const entries = Array.isArray(data) ? data : (data.summarizedActivitiesExport || []);
                        setBackfillProgress({ status: "importing", message: `Activities: scanning ${entries.length} entries...` });

                        // Load existing Strava activities for matching
                        const stravaSnap = await db.collection("users").doc(userId).collection("activities")
                          .orderBy("start_date", "desc").limit(2000).get();
                        const stravaActs = stravaSnap.docs.map(d => ({
                          docId: d.id, ...d.data(),
                          _startMs: d.data().start_date?.toDate ? d.data().start_date.toDate().getTime() : new Date(d.data().start_date).getTime(),
                          _hasDynamics: !!d.data().runalyze,
                        }));

                        for (const ga of entries) {
                          try {
                            const gaGMT = ga.startTimeGMT || ga.startTimeLocal || "";
                            const gaMs = ga.beginTimestamp || new Date(gaGMT.replace(" ", "T") + (ga.startTimeGMT ? "Z" : "")).getTime();
                            const gaDistM = ga.distance || 0;
                            const gaDuration = ga.duration || ga.movingDuration || 0;
                            if (!gaMs) { stats.skipped++; continue; }

                            // Match to Strava activity
                            const matched = stravaActs.find(sa => {
                              const timeDiff = Math.abs(sa._startMs - gaMs);
                              if (timeDiff > 600000) return false;
                              const distR = sa.distance > 0 && gaDistM > 0 ? Math.min(sa.distance, gaDistM) / Math.max(sa.distance, gaDistM) : 0;
                              const durR = sa.moving_time > 0 && gaDuration > 0 ? Math.min(sa.moving_time, gaDuration) / Math.max(sa.moving_time, gaDuration) : 0;
                              return distR > 0.8 || durR > 0.8;
                            });

                            if (!matched || matched._hasDynamics) { stats.skipped++; continue; }

                            // Extract running dynamics from activity summary
                            const dynamics = {};
                            if (ga.avgGroundContactTime) dynamics.groundContactTime = Math.round(ga.avgGroundContactTime);
                            if (ga.avgGroundContactBalance) dynamics.groundContactBalance = parseFloat(ga.avgGroundContactBalance.toFixed(1));
                            if (ga.avgVerticalOscillation) dynamics.verticalOscillation = parseFloat(ga.avgVerticalOscillation.toFixed(1));
                            if (ga.avgVerticalRatio) dynamics.verticalRatio = parseFloat(ga.avgVerticalRatio.toFixed(1));
                            // Garmin reports avgStrideLength in centimetres; store
                            // metres to match every other extraction path (the chart
                            // re-multiplies by 100 for display).
                            if (ga.avgStrideLength) dynamics.strideLengthSensor = parseFloat((ga.avgStrideLength / 100).toFixed(2));
                            // Step Speed Loss — Garmin's running-economy
                            // metric (cm/s of velocity decay between
                            // footstrikes). The live syncGarminDynamics
                            // path captures it; the ZIP backfill was
                            // missing it, so historical activities never
                            // got the field.
                            if (ga.avgStepSpeedLoss != null) dynamics.stepSpeedLoss = parseFloat(Number(ga.avgStepSpeedLoss).toFixed(1));
                            const sslPct = ga.avgStepSpeedLossPercent ?? ga.avgStepSpeedLossPercentage;
                            if (sslPct != null) dynamics.stepSpeedLossPct = parseFloat(Number(sslPct).toFixed(2));
                            const cad = ga.averageRunningCadenceInStepsPerMinute || ga.avgRunCadence || ga.avgDoubleCadence;
                            if (cad) dynamics.cadenceSpm = Math.round(cad);
                            if (ga.maxRunningCadenceInStepsPerMinute) dynamics.maxCadenceSpm = Math.round(ga.maxRunningCadenceInStepsPerMinute);
                            if (ga.avgPower) dynamics.power = Math.round(ga.avgPower);
                            if (ga.maxPower) dynamics.maxPower = Math.round(ga.maxPower);
                            if (ga.aerobicTrainingEffect) dynamics.trainingEffect = parseFloat(ga.aerobicTrainingEffect.toFixed(1));
                            if (ga.anaerobicTrainingEffect) dynamics.anaerobicTrainingEffect = parseFloat(ga.anaerobicTrainingEffect.toFixed(1));
                            if (ga.activityTrainingLoad) dynamics.exerciseLoad = Math.round(ga.activityTrainingLoad);
                            if (ga.vO2MaxValue) dynamics.garminVO2max = parseFloat(ga.vO2MaxValue.toFixed(1));
                            if (ga.avgRespirationRate) dynamics.respirationRate = parseFloat(ga.avgRespirationRate.toFixed(1));
                            if (ga.averageTemperature != null) dynamics.avgTemperature = parseFloat(ga.averageTemperature.toFixed(1));
                            if (ga.minTemperature != null) dynamics.minTemperature = parseFloat(ga.minTemperature.toFixed(1));
                            if (ga.maxTemperature != null) dynamics.maxTemperature = parseFloat(ga.maxTemperature.toFixed(1));
                            const actType = (ga.activityType?.typeKey || "").toLowerCase();
                            if (actType.includes("treadmill") || actType.includes("indoor")) dynamics.isIndoor = true;

                            // Coach-extra fields: HR time-in-zones, elevation
                            // loss, grade-adjusted speed, body-battery delta.
                            // Inline extraction (this file is browser-side, no
                            // shared helper available without a refactor).
                            const zoneSec = {};
                            if (Array.isArray(ga.hrTimeInZones)) {
                              for (const z of ga.hrTimeInZones) {
                                const num = z?.zoneNumber ?? z?.zone;
                                const sec = z?.secsInZone ?? z?.timeInZone ?? z?.timeInSeconds;
                                if (Number.isFinite(num) && Number.isFinite(sec) && sec > 0) zoneSec[`z${num}`] = Math.round(sec);
                              }
                            } else {
                              for (let n = 1; n <= 5; n++) {
                                const v = ga[`hrTimeInZone_${n}`] ?? ga[`timeInHrZone${n}`];
                                if (Number.isFinite(v) && v > 0) zoneSec[`z${n}`] = Math.round(v);
                              }
                            }
                            if (Object.keys(zoneSec).length) dynamics.hrTimeInZones = zoneSec;
                            const elLoss = ga.elevationLoss ?? ga.totalDescent;
                            if (Number.isFinite(elLoss) && elLoss >= 0) dynamics.elevationLoss = Math.round(elLoss * 100) / 100;
                            const gapSpeed = ga.avgGradeAdjustedSpeed ?? ga.weightedAvgGradeAdjustedSpeed ?? ga.gapSpeed;
                            if (Number.isFinite(gapSpeed) && gapSpeed > 0) dynamics.gradeAdjustedSpeed = parseFloat(gapSpeed.toFixed(3));
                            const bbDelta = ga.differenceBodyBattery ?? ga.bodyBatteryDifference ?? ga.bodyBatteryDelta;
                            if (Number.isFinite(bbDelta) && bbDelta !== 0) dynamics.bodyBatteryDelta = Math.round(bbDelta);

                            if (Object.keys(dynamics).length === 0) { stats.skipped++; continue; }

                            await db.collection("users").doc(userId).collection("activities").doc(matched.docId)
                              .update({
                                dynamics: dynamics, // Garmin running dynamics
                                runalyze: dynamics, // backward compat with cloud functions
                                garminActivityId: String(ga.activityId || ""),
                                runalyze_synced_at: firebase.firestore.FieldValue.serverTimestamp(),
                              });
                            matched._hasDynamics = true;
                            stats.activities++;
                          } catch (e) { stats.skipped++; }
                        }
                      } catch (e) { stats.errors++; console.warn("Activity parse error:", fname, e.message); }
                      setBackfillProgress({ status: "importing", message: `Activities: ${stats.activities} enriched...` });
                    }

                    setBackfillProgress({ status: "complete", ...stats });
                    setSuccess(`Import complete! ${stats.healthDays} health days, ${stats.sleepDays} sleep days, ${stats.activities} activities enriched${stats.errors ? `, ${stats.errors} errors` : ""}`);
                  } catch (err) {
                    setError("Import failed: " + err.message);
                    setBackfillProgress(null);
                  }
                  setBackfilling(false);
                };

                const bp = backfillProgress;
                return (
                  <div style={{ marginTop: 8, padding: "12px 14px", background: C.bg2, borderRadius: 8, border: `1px solid ${C.border}` }}>
                    <div style={{ fontSize: 12, color: C.textMuted, lineHeight: 1.6, marginBottom: 10 }}>
                      Import your full Garmin history from a data export. Go to{" "}
                      <a href="https://www.garmin.com/account/datamanagement/" target="_blank" rel="noopener" style={{ color: C.cyan }}>
                        garmin.com/account/datamanagement
                      </a>
                      {" "}→ Export Your Data → download the ZIP when ready, then upload it here.
                      Imports daily wellness (resting HR, stress, steps), sleep data, and enriches activities with running dynamics.
                    </div>

                    {/* File upload */}
                    {(!bp || bp.status === "complete") && (
                      <div>
                        <label style={{
                          display: "flex", alignItems: "center", justifyContent: "center", gap: 8,
                          padding: "14px 20px", borderRadius: 8, cursor: backfilling ? "not-allowed" : "pointer",
                          border: `2px dashed ${C.border}`, background: C.bg,
                          color: backfilling ? C.textMuted : C.cyan, fontSize: 13, fontWeight: 600,
                          fontFamily: "'IBM Plex Mono',monospace",
                          transition: "border-color 0.2s, background 0.2s",
                        }}>
                          <input type="file" accept=".zip" style={{ display: "none" }} disabled={backfilling}
                            onChange={(e) => { const f = e.target.files?.[0]; if (f) importGarminZip(f); e.target.value = ""; }}
                          />
                          {backfilling ? "Importing..." : "Upload Garmin Export ZIP"}
                        </label>
                      </div>
                    )}

                    {/* Progress */}
                    {bp && bp.status !== "complete" && (
                      <div style={{ marginTop: 8, fontSize: 11, color: C.cyan }}>
                        {bp.message || "Processing..."}
                      </div>
                    )}

                    {/* Complete */}
                    {bp?.status === "complete" && (
                      <div style={{ marginTop: 8, fontSize: 11, color: C.green, fontWeight: 600 }}>
                        Import complete — {bp.healthDays || 0} health days, {bp.sleepDays || 0} sleep days, {bp.activities || 0} activities enriched
                        {bp.skipped > 0 && <span style={{ color: C.textMuted, fontWeight: 400 }}> · {bp.skipped} skipped (no Strava match or already enriched)</span>}
                      </div>
                    )}
                  </div>
                );
              })()}
            </div>

            {/* Manual cookie paste — collapsible fallback */}
            <div style={{ marginTop: 12 }}>
              <button onClick={() => setCookiePaste(cookiePaste === null ? "" : null)} style={{
                background: "none", border: "none", cursor: "pointer", fontSize: 11, color: C.textMuted, padding: 0,
              }}>
                {cookiePaste !== null ? "▾" : "▸"} Paste cookies manually
              </button>
              {cookiePaste !== null && (
                <div style={{ display: "flex", gap: 8, marginTop: 8 }}>
                  <textarea
                    value={cookiePaste}
                    onChange={e => setCookiePaste(e.target.value)}
                    placeholder="Paste base64 from garmin_setup.py..."
                    rows={2}
                    style={{
                      flex: 1, background: C.bg2, color: C.text, border: `1px solid ${C.border}`,
                      borderRadius: 8, padding: "8px 10px", fontSize: 11, fontFamily: "'IBM Plex Mono',monospace",
                      resize: "vertical",
                    }}
                  />
                  <button onClick={async () => {
                    if (!cookiePaste.trim()) return;
                    setSavingCookies(true);
                    setError(null);
                    try {
                      const decoded = atob(cookiePaste.trim());
                      JSON.parse(decoded);
                      const now = new Date();
                      const expires = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000);
                      await db.doc("config/garmin_scraper_cookies").set({
                        cookies: cookiePaste.trim(),
                        updatedAt: firebase.firestore.FieldValue.serverTimestamp(),
                        expiresApprox: expires,
                      });
                      setScraperStatus({ updatedAt: now, expiresApprox: expires, hasCookies: true });
                      setCookiePaste(null);
                      setSuccess("Garmin cookies saved.");
                    } catch (err) {
                      setError("Invalid cookies: " + (err.message || "must be base64-encoded JSON"));
                    } finally { setSavingCookies(false); }
                  }} disabled={savingCookies || !cookiePaste?.trim()} style={{
                    background: "#007CC3", color: "#fff", border: "none", borderRadius: 8,
                    padding: "10px 16px", fontSize: 12, fontWeight: 600, cursor: "pointer",
                    opacity: savingCookies || !cookiePaste?.trim() ? 0.5 : 1, whiteSpace: "nowrap",
                  }}>
                    {savingCookies ? "Saving..." : "Save"}
                  </button>
                </div>
              )}
            </div>
          </Card>
          <Card style={{ marginBottom: 16 }}>
            <div style={{ display: "flex", alignItems: "center", gap: 16 }}>
              <div style={{ fontSize: 32, width: 48, textAlign: "center" }}>🍎</div>
              <div style={{ flex: 1 }}>
                <div style={{ fontWeight: 700, fontSize: 15, color: C.text, marginBottom: 4 }}>MyFitnessPal</div>
                {mfpStatus && mfpStatus.connected ? (
                  <div style={{ fontSize: 12, color: C.textMuted, lineHeight: 1.5 }}>
                    Connected as <strong style={{ color: C.green }}>{mfpStatus.username}</strong>
                    {mfpStatus.lastSync ? <span style={{ display: "block", fontSize: 10, color: C.green, marginTop: 4 }}>Last sync: {new Date(mfpStatus.lastSync).toLocaleString()}</span> : null}
                  </div>
                ) : (
                  <div style={{ fontSize: 10, color: C.amber, marginTop: 4 }}>
                    Not connected — set up on the Nutrition tab
                  </div>
                )}
              </div>
              {mfpStatus && mfpStatus.connected && (
                <button onClick={startMfpPopupSync} style={{
                  background: C.cyan, color: "#000", border: "none", borderRadius: 8,
                  padding: "10px 18px", fontSize: 12, fontWeight: 700, cursor: "pointer",
                  fontFamily: "'IBM Plex Mono',monospace", flexShrink: 0,
                }}>
                  Sync Now
                </button>
              )}
            </div>
            <div style={{ fontSize: 11, color: C.textMuted, marginTop: 10, padding: "6px 10px", background: C.bg2, borderRadius: 6, lineHeight: 1.5 }}>
              Auto-syncs when you visit MFP via the Tampermonkey extension. Click "Sync 7 days" on MFP for a full backfill.
            </div>
          </Card>


          {/* Running Dynamics — Garmin Connect direct (primary) + Runalyze (future) */}
          <Card style={{ marginBottom: 16 }}>
            <div style={{ display: "flex", alignItems: "center", gap: 16 }}>
              <div style={{ fontSize: 32, width: 48, textAlign: "center" }}>📊</div>
              <div style={{ flex: 1 }}>
                <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
                  <span style={{ fontWeight: 700, fontSize: 15, color: C.text }}>Running Dynamics</span>
                  {dynamicsStatus && dynamicsStatus.synced > 0 && (
                    <span style={{ fontSize: 10, padding: "2px 8px", borderRadius: 12, background: `${C.green}20`, color: C.green, fontWeight: 600 }}>Active</span>
                  )}
                </div>
                <div style={{ fontSize: 12, color: C.textMuted, lineHeight: 1.5, marginTop: 4 }}>
                  GCT, vertical oscillation, running power, training effect, recovery time — pulled from Garmin Connect and merged onto Strava activities
                </div>
                {dynamicsStatus && dynamicsStatus.lastSync && (
                  <div style={{ fontSize: 10, color: C.green, marginTop: 4 }}>
                    Last sync: {new Date(dynamicsStatus.lastSync).toLocaleString()} · {dynamicsStatus.synced || 0} activities enriched
                  </div>
                )}
              </div>
              <div style={{ display: "flex", gap: 8, flexShrink: 0 }}>
                <button onClick={() => syncGarminDynamics(false)} disabled={syncing === "dynamics"} style={{
                  background: "#007CC3", color: "#fff", border: "none", borderRadius: 8,
                  padding: "10px 16px", fontSize: 12, fontWeight: 600, cursor: "pointer",
                  opacity: syncing === "dynamics" ? 0.6 : 1, fontFamily: "'IBM Plex Mono',monospace",
                }}>
                  {syncing === "dynamics" ? "Syncing..." : "Sync (30 days)"}
                </button>
                <button onClick={() => syncGarminDynamics(true)} disabled={syncing === "dynamics"} style={{
                  background: "transparent", color: C.textDim, border: `1px solid ${C.border}`, borderRadius: 8,
                  padding: "10px 12px", fontSize: 11, cursor: "pointer", fontFamily: "'IBM Plex Mono',monospace",
                }}>Full Year</button>
              </div>
            </div>
            <div style={{ marginTop: 12, padding: "10px 14px", background: C.bg2, borderRadius: 8, fontSize: 11, color: C.textMuted, lineHeight: 1.6 }}>
              Uses your existing Garmin Connect session. Activities matched to Strava by date (±5min) and distance/duration (±20%) — no duplicates.
              Requires active Garmin Connect session (click "Sync" on Garmin Connect above if expired).
            </div>
            {/* Runalyze integration */}
            <div style={{ marginTop: 10, padding: "12px 14px", background: C.bg2, borderRadius: 8, border: `1px solid ${C.border}` }}>
              <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 6 }}>
                <span style={{ fontSize: 12, fontWeight: 700, color: C.text }}>Runalyze</span>
                {dynamicsStatus?.connected
                  ? <span style={{ fontSize: 9, padding: "1px 6px", borderRadius: 4, background: C.green+"20", color: C.green, fontWeight: 600 }}>Connected</span>
                  : <span style={{ fontSize: 9, padding: "1px 6px", borderRadius: 4, background: C.border, color: C.textMuted, fontWeight: 600 }}>Not Connected</span>
                }
              </div>
              <div style={{ fontSize: 10, color: C.textMuted, lineHeight: 1.5, marginBottom: 8 }}>
                Alternative running dynamics data path via Runalyze Personal API. Requires Supporter/Premium account with API read access enabled.
              </div>
              {dynamicsStatus?.lastSync && (
                <div style={{ fontSize: 10, color: C.green, marginBottom: 8 }}>
                  Last sync: {new Date(dynamicsStatus.lastSync).toLocaleString()} · {dynamicsStatus.synced || 0} activities enriched
                </div>
              )}
              <div style={{ display: "flex", gap: 8, alignItems: "center", marginBottom: dynamicsStatus?.connected ? 8 : 0 }}>
                <input type="password" value={runalyzeToken} onChange={e => setRunalyzeToken(e.target.value)}
                  placeholder="Runalyze Personal API token"
                  style={{ flex: 1, padding: "6px 8px", borderRadius: 4, border: `1px solid ${C.border}`,
                    background: C.bg, color: C.text, fontSize: 11, fontFamily: "'IBM Plex Mono',monospace", outline: "none" }} />
                <button onClick={saveRunalyzeToken} disabled={!runalyzeToken.trim()} style={{
                  padding: "6px 12px", borderRadius: 4, border: `1px solid ${C.border}`,
                  background: "transparent", color: C.textDim, fontSize: 11, cursor: runalyzeToken.trim() ? "pointer" : "default",
                  fontFamily: "'IBM Plex Mono',monospace", opacity: runalyzeToken.trim() ? 1 : 0.5,
                }}>Save</button>
              </div>
              {dynamicsStatus?.connected && (
                <div style={{ display: "flex", gap: 8 }}>
                  <button onClick={() => syncRunalyzeAPI(false)} disabled={syncing === "runalyzeAPI"} style={{
                    background: "#e83e3e", color: "#fff", border: "none", borderRadius: 6,
                    padding: "8px 14px", fontSize: 11, fontWeight: 600, cursor: "pointer",
                    opacity: syncing === "runalyzeAPI" ? 0.6 : 1, fontFamily: "'IBM Plex Mono',monospace",
                  }}>
                    {syncing === "runalyzeAPI" ? "Syncing..." : "Sync (recent)"}
                  </button>
                  <button onClick={() => syncRunalyzeAPI(true)} disabled={syncing === "runalyzeAPI"} style={{
                    background: "transparent", color: C.textDim, border: `1px solid ${C.border}`, borderRadius: 6,
                    padding: "8px 12px", fontSize: 10, cursor: "pointer", fontFamily: "'IBM Plex Mono',monospace",
                  }}>Full (2yr)</button>
                  <button onClick={inspectRunalyze} disabled={inspecting} style={{
                    background: "transparent", color: C.cyan, border: `1px solid ${C.cyan}66`, borderRadius: 6,
                    padding: "8px 12px", fontSize: 10, cursor: "pointer", fontFamily: "'IBM Plex Mono',monospace",
                    opacity: inspecting ? 0.6 : 1,
                  }}>{inspecting ? "Inspecting…" : "Inspect data"}</button>
                </div>
              )}
              {runalyzeInspect && (() => {
                const r = runalyzeInspect;
                const okProbes = (r.probes || []).filter(p => p.ok);
                const fitProbe = (r.probes || []).find(p => /\/fit$|\/original$/.test(p.path));
                const mono = { fontFamily: "'IBM Plex Mono',monospace" };
                return (
                  <div style={{ marginTop: 12, padding: "12px 14px", background: C.bg, borderRadius: 8, border: `1px solid ${C.border}` }}>
                    <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 8 }}>
                      <span style={{ fontSize: 11, fontWeight: 700, color: C.cyan }}>Runalyze data surface</span>
                      <button onClick={() => setRunalyzeInspect(null)} style={{ background: "none", border: "none", color: C.textMuted, cursor: "pointer", fontSize: 14 }}>×</button>
                    </div>
                    <div style={{ fontSize: 10, color: C.textDim, lineHeight: 1.6, marginBottom: 10, ...mono }}>
                      <div><strong style={{ color: C.text }}>{r.list?.count ?? 0}</strong> activities · <strong style={{ color: C.text }}>{r.fieldCount}</strong> distinct fields{r.list?.dateRange ? ` · ${r.list.dateRange.from} → ${r.list.dateRange.to}` : ""}</div>
                      <div style={{ marginTop: 2 }}>List endpoint: {r.listEndpoint
                        ? <span style={{ color: C.green }}>{r.listEndpoint} ✓</span>
                        : <span style={{ color: C.amber }}>none returned rows</span>}
                        {(r.listReport || []).length ? ` (${r.listReport.map(l => `${l.path}: ${l.rows} rows [${l.status}]`).join(" · ")})` : ""}
                      </div>
                      <div style={{ marginTop: 2, color: fitProbe?.ok ? C.green : C.amber }}>FIT export: {fitProbe ? (fitProbe.ok ? `available (${fitProbe.size || "?"} bytes @ ${fitProbe.path})` : `not at probed paths (status ${fitProbe.status})`) : "not probed (no activity id)"}</div>
                    </div>
                    {okProbes.length > 0 && (
                      <div style={{ fontSize: 10, color: C.textDim, marginBottom: 10, ...mono }}>
                        <div style={{ color: C.textMuted, marginBottom: 3 }}>Live endpoints &amp; their fields:</div>
                        {okProbes.map((p, i) => (
                          <div key={i} style={{ marginBottom: 2 }}>
                            <span style={{ color: C.green }}>{p.path}</span>
                            {p.rows != null ? <span style={{ color: C.textMuted }}> [{p.rows} rows]</span> : null}
                            {p.topKeys ? <span style={{ color: C.textMuted }}>: {p.topKeys.join(", ")}</span> : null}
                          </div>
                        ))}
                      </div>
                    )}
                    <div style={{ maxHeight: 260, overflowY: "auto", border: `1px solid ${C.border}`, borderRadius: 6 }}>
                      <table style={{ width: "100%", borderCollapse: "collapse", fontSize: 10, ...mono }}>
                        <thead><tr style={{ position: "sticky", top: 0, background: C.bg2 }}>
                          <th style={{ textAlign: "left", padding: "4px 6px", color: C.textMuted }}>field</th>
                          <th style={{ textAlign: "left", padding: "4px 6px", color: C.textMuted }}>type</th>
                          <th style={{ textAlign: "left", padding: "4px 6px", color: C.textMuted }}>cov</th>
                          <th style={{ textAlign: "left", padding: "4px 6px", color: C.textMuted }}>sample</th>
                        </tr></thead>
                        <tbody>
                          {(r.fieldInventory || []).map((f, i) => (
                            <tr key={i} style={{ borderTop: `1px solid ${C.border}` }}>
                              <td style={{ padding: "3px 6px", color: C.text }}>{f.path}</td>
                              <td style={{ padding: "3px 6px", color: C.textDim }}>{f.type}{f.maxLen ? `[${f.maxLen}]` : ""}</td>
                              <td style={{ padding: "3px 6px", color: C.textMuted }}>{f.coverage}</td>
                              <td style={{ padding: "3px 6px", color: C.textMuted, maxWidth: 160, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{String(f.sample)}</td>
                            </tr>
                          ))}
                        </tbody>
                      </table>
                    </div>
                  </div>
                );
              })()}
            </div>
          </Card>

          <Card style={{ marginTop: 24, borderStyle: "dashed" }}>
            <h3 style={{ fontSize: 14, fontWeight: 700, color: C.textDim, marginBottom: 8 }}>Data Flow</h3>
            <div style={{ fontSize: 12, color: C.textMuted, lineHeight: 1.8 }}>
              <div>📱 <strong style={{color:C.text}}>Garmin Watch</strong> → Garmin Connect → <strong style={{color:C.green}}>Garmin Connect Sync</strong> (Body Battery, HRV, stress, SpO2, respiration, training readiness)</div>
              <div>📱 <strong style={{color:C.text}}>Garmin Watch</strong> → Garmin Connect → Strava → <strong style={{color:C.amber}}>Strava API</strong> (activities, routes)</div>
              <div>📱 <strong style={{color:C.text}}>Garmin Watch</strong> → Garmin Connect → <strong style={{color:"#4f46e5"}}>Running Dynamics Sync</strong> (GCT, vertical oscillation, power, training effect, recovery)</div>
              <div>📊 <strong style={{color:C.text}}>Runalyze</strong> → <strong style={{color:"#e83e3e"}}>Runalyze Sync</strong> (alternative running dynamics via Personal API)</div>
              <div>🍎 <strong style={{color:C.text}}>MyFitnessPal</strong> → <strong style={{color:C.cyan}}>MFP Sync</strong> (nutrition data)</div>
            </div>
          </Card>

          {/* Training Methodology */}
          <h3 style={{ fontSize: 14, fontWeight: 700, color: C.textDim, marginBottom: 12, marginTop: 32, textTransform: "uppercase", letterSpacing: "0.05em" }}>Training Methodology</h3>
          <Card style={{ marginBottom: 16 }}>
            <div style={{ fontSize: 13, color: C.textMuted, marginBottom: 12 }}>
              Your default training philosophy. The coach, training plans, activity analysis, and daily summaries all follow this methodology.
            </div>
            <div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginBottom: 16 }}>
              {[
                { id: "polarized", name: "Polarized (80/20)", desc: "80% easy below VT1, 20% hard above VT2. Minimal time in the gray zone. Research-backed as the most effective model for endurance improvement.", color: C.cyan },
                { id: "pyramidal", name: "Pyramidal", desc: "High volume at low intensity, decreasing volume as intensity increases. Traditional approach used by most elite distance runners.", color: C.green },
                { id: "threshold", name: "Threshold-focused", desc: "Emphasizes tempo and threshold work (Zone 3-4). Targets lactate clearance for race-pace improvement.", color: C.amber },
                { id: "maf", name: "MAF (Maffetone)", desc: "Nearly all training at or below MAF HR (180 - age). Builds massive aerobic base before any speed work.", color: C.purple },
                { id: "hybrid", name: "Hybrid / Periodized", desc: "Shifts between methodologies across training phases. Base → threshold → race-specific sharpening.", color: C.pink },
              ].map(m => (
                <button key={m.id} onClick={() => saveMethodology(m.id)} style={{
                  padding: "10px 14px", borderRadius: 10, cursor: "pointer", textAlign: "left", flex: "1 1 200px", minWidth: 200,
                  background: trainingMethodology === m.id ? m.color + "18" : C.bg2,
                  border: `1px solid ${trainingMethodology === m.id ? m.color : C.border}`,
                  transition: "all 0.2s",
                }}>
                  <div style={{ fontSize: 13, fontWeight: 700, color: trainingMethodology === m.id ? m.color : C.text, marginBottom: 4 }}>
                    {m.name} {trainingMethodology === m.id && "✓"}
                  </div>
                  <div style={{ fontSize: 11, color: C.textMuted, lineHeight: 1.4 }}>{m.desc}</div>
                </button>
              ))}
            </div>
            {trainingMethodology === "polarized" && (
              <div style={{ padding: "10px 14px", background: C.cyan + "10", borderRadius: 8, border: `1px solid ${C.cyan}25`, fontSize: 12, color: C.textDim, lineHeight: 1.5 }}>
                <strong style={{ color: C.cyan }}>80/20 Rule:</strong> 80% of training volume at easy/conversational effort (below VT1), 20% at hard effort (above VT2).
                Avoid the "gray zone" — moderately hard efforts that feel productive but accumulate fatigue without the benefits of truly easy or truly hard training.
                The coach will flag when you drift into the gray zone and ensure your easy days are genuinely easy.
              </div>
            )}
          </Card>

          {/* HR Zone Advisor */}
          <h3 style={{ fontSize: 14, fontWeight: 700, color: C.textDim, marginBottom: 12, marginTop: 32, textTransform: "uppercase", letterSpacing: "0.05em" }}>HR Zone Advisor</h3>

          <Card style={{ marginBottom: 16 }}>
            <div style={{ fontSize: 13, color: C.textMuted, marginBottom: 16 }}>
              Claude analyzes your activity data and health metrics to recommend personalized HR training zones and the best methodology for you.
            </div>

            <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(140px, 1fr))", gap: 12, marginBottom: 16 }}>
              <div>
                <label style={{ fontSize: 11, color: C.textMuted, display: "block", marginBottom: 4 }}>Age</label>
                <input type="number" value={hrAge} onChange={e => setHrAge(e.target.value)} placeholder="e.g. 35"
                  style={{ width: "100%", padding: "8px 10px", borderRadius: 6, border: `1px solid ${C.border}`, background: C.bg2, color: C.text, fontSize: 13, fontFamily: "'IBM Plex Mono',monospace", outline: "none", boxSizing: "border-box" }} />
              </div>
              <div>
                <label style={{ fontSize: 11, color: C.textMuted, display: "block", marginBottom: 4 }}>Gender</label>
                <select value={hrGender} onChange={e => setHrGender(e.target.value)}
                  style={{ width: "100%", padding: "8px 10px", borderRadius: 6, border: `1px solid ${C.border}`, background: C.bg2, color: C.text, fontSize: 13, fontFamily: "'IBM Plex Mono',monospace", outline: "none", boxSizing: "border-box" }}>
                  <option value="male">Male</option>
                  <option value="female">Female</option>
                </select>
              </div>
              <div>
                <label style={{ fontSize: 11, color: C.textMuted, display: "block", marginBottom: 4 }}>Resting HR</label>
                <input type="number" value={hrResting} onChange={e => setHrResting(e.target.value)} placeholder="auto-detect"
                  style={{ width: "100%", padding: "8px 10px", borderRadius: 6, border: `1px solid ${C.border}`, background: C.bg2, color: C.text, fontSize: 13, fontFamily: "'IBM Plex Mono',monospace", outline: "none", boxSizing: "border-box" }} />
              </div>
              <div>
                <label style={{ fontSize: 11, color: C.textMuted, display: "block", marginBottom: 4 }}>Max HR (observed)</label>
                <input type="number" value={hrMaxObs} onChange={e => setHrMaxObs(e.target.value)} placeholder="auto-detect"
                  style={{ width: "100%", padding: "8px 10px", borderRadius: 6, border: `1px solid ${C.border}`, background: C.bg2, color: C.text, fontSize: 13, fontFamily: "'IBM Plex Mono',monospace", outline: "none", boxSizing: "border-box" }} />
              </div>
              <div>
                <label style={{ fontSize: 11, color: C.textMuted, display: "block", marginBottom: 4 }}>LTHR (if known)</label>
                <input type="number" value={hrLTHR} onChange={e => setHrLTHR(e.target.value)} placeholder="optional"
                  style={{ width: "100%", padding: "8px 10px", borderRadius: 6, border: `1px solid ${C.border}`, background: C.bg2, color: C.text, fontSize: 13, fontFamily: "'IBM Plex Mono',monospace", outline: "none", boxSizing: "border-box" }} />
              </div>
            </div>

            <div style={{ marginBottom: 16 }}>
              <label style={{ fontSize: 11, color: C.textMuted, display: "block", marginBottom: 4 }}>Preferences (optional)</label>
              <input type="text" value={hrPreference} onChange={e => setHrPreference(e.target.value)}
                placeholder="e.g. 'Prefer Karvonen method' or 'Training for a marathon'"
                style={{ width: "100%", padding: "8px 10px", borderRadius: 6, border: `1px solid ${C.border}`, background: C.bg2, color: C.text, fontSize: 13, fontFamily: "'IBM Plex Mono',monospace", outline: "none", boxSizing: "border-box" }} />
            </div>

            <button onClick={runHRAnalysis} disabled={hrAnalyzing} style={{
              padding: "10px 24px", borderRadius: 8, border: "none",
              background: hrAnalyzing ? C.bg3 : "linear-gradient(135deg, #7dd3fc, #34d399)",
              color: hrAnalyzing ? C.textMuted : "#000", fontSize: 13, fontWeight: 700,
              cursor: hrAnalyzing ? "not-allowed" : "pointer", fontFamily: "'IBM Plex Mono',monospace",
            }}>
              {hrAnalyzing ? "Analyzing your data..." : "Analyze & Recommend Zones"}
            </button>
          </Card>

          {/* HR Zone Results */}
          {effectiveHRResult && effectiveHRResult.zones && (
            <Card style={{ marginBottom: 16, borderColor: C.cyan + "40" }}>
              <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 16 }}>
                <div>
                  <div style={{ fontSize: 15, fontWeight: 700, color: C.text }}>Your HR Zones</div>
                  <div style={{ fontSize: 12, color: C.textMuted, marginTop: 2 }}>
                    Methodology: <span style={{ color: C.cyan, fontWeight: 600 }}>{effectiveHRResult.methodology?.replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase())}</span>
                    {effectiveHRResult.methodologyReason && ` — ${effectiveHRResult.methodologyReason}`}
                    {effectiveHRResult.methodology !== hrResult?.methodology && (
                      <span style={{ color: C.amber, marginLeft: 8 }}>(overridden by training methodology)</span>
                    )}
                  </div>
                </div>
                {effectiveHRResult.analyzedAt && (
                  <span style={{ fontSize: 10, color: C.textMuted }}>
                    Last analyzed: {effectiveHRResult.analyzedAt?.toDate ? effectiveHRResult.analyzedAt.toDate().toLocaleDateString() : "just now"}
                  </span>
                )}
              </div>

              {/* Max HR & Resting HR info */}
              <div style={{ display: "flex", gap: 24, marginBottom: 16, fontSize: 12, color: C.textMuted }}>
                <div>Max HR: <span style={{ color: C.text, fontWeight: 600 }}>{effectiveHRResult.maxHR} bpm</span> ({effectiveHRResult.maxHRSource || "calculated"})</div>
                <div>Resting HR: <span style={{ color: C.text, fontWeight: 600 }}>{effectiveHRResult.restingHR} bpm</span></div>
                {effectiveHRResult.lthr && <div>LTHR: <span style={{ color: C.text, fontWeight: 600 }}>{effectiveHRResult.lthr} bpm</span>{effectiveHRResult.lthrEstimated ? " (estimated)" : ""}</div>}
              </div>

              {/* Zone table */}
              <div style={{ display: "flex", flexDirection: "column", gap: 0 }}>
                {effectiveHRResult.zones.map((z, i) => {
                  const colors = [C.textMuted, C.green, C.amber, "#f97316", C.red];
                  const isPolarized = effectiveHRResult.methodology === "polarized";
                  // Insert "Hard 20%" group header before Z3 in polarized
                  const showHardGroupHeader = isPolarized && z.zone === 3;
                  return (
                    <React.Fragment key={i}>
                      {showHardGroupHeader && (
                        <div style={{
                          display: "flex", alignItems: "center", gap: 8,
                          padding: "6px 0 2px", marginTop: 4,
                          borderTop: `1px solid ${C.border}`,
                        }}>
                          <div style={{
                            fontSize: 10, fontWeight: 700, color: "#f97316",
                            background: "#f9731615", border: "1px solid #f9731630",
                            padding: "2px 8px", borderRadius: 4, letterSpacing: "0.05em",
                          }}>HARD 20%</div>
                          <div style={{ fontSize: 10, color: C.textMuted }}>Z3 + Z4 + Z5 combined</div>
                        </div>
                      )}
                      <div style={{
                        display: "grid", gridTemplateColumns: "80px 100px 1fr 60px",
                        gap: 8, alignItems: "center", padding: "10px 0",
                        borderBottom: i < effectiveHRResult.zones.length - 1 ? `1px solid ${C.border}` : "none",
                        ...(isPolarized && z.zone >= 3 ? { paddingLeft: 8, background: "#f9731605" } : {}),
                      }}>
                        <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
                          <div style={{ width: 8, height: 8, borderRadius: 4, background: colors[i] }} />
                          <span style={{ fontSize: 12, fontWeight: 700, color: colors[i] }}>Z{z.zone}</span>
                        </div>
                        <div style={{ fontSize: 14, fontWeight: 700, color: C.text, fontFamily: "'Space Grotesk',sans-serif" }}>
                          {z.minBPM}–{z.maxBPM} <span style={{ fontSize: 10, color: C.textMuted }}>bpm</span>
                        </div>
                        <div style={{ fontSize: 12, color: C.textDim }}>
                          <span style={{ fontWeight: 600 }}>{z.name}</span> — {z.purpose}
                        </div>
                        <div style={{ fontSize: 11, color: C.textMuted, textAlign: "right" }}>
                          {z.weeklyPct > 0 ? `${z.weeklyPct}%` : "—"}
                        </div>
                      </div>
                    </React.Fragment>
                  );
                })}
              </div>

              {/* Insights */}
              {hrResult.insights && hrResult.insights.length > 0 && (
                <div style={{ marginTop: 16, paddingTop: 16, borderTop: `1px solid ${C.border}` }}>
                  <div style={{ fontSize: 12, fontWeight: 600, color: C.textDim, marginBottom: 8 }}>Insights</div>
                  {hrResult.insights.map((ins, i) => (
                    <div key={i} style={{ fontSize: 12, color: C.textMuted, padding: "4px 0", display: "flex", gap: 8 }}>
                      <span style={{ color: C.cyan }}>•</span>
                      <span>{ins}</span>
                    </div>
                  ))}
                </div>
              )}

              {/* Copy to Strava / Garmin instructions */}
              <div style={{ marginTop: 16, paddingTop: 16, borderTop: `1px solid ${C.border}`, display: "flex", gap: 12 }}>
                <button onClick={() => setHrShowInstructions(hrShowInstructions === "strava" ? null : "strava")} style={{
                  padding: "8px 16px", borderRadius: 6, border: `1px solid ${C.border}`,
                  background: hrShowInstructions === "strava" ? "#FC4C0220" : "transparent",
                  color: hrShowInstructions === "strava" ? "#FC4C02" : C.textDim,
                  fontSize: 12, fontWeight: 600, cursor: "pointer", fontFamily: "'IBM Plex Mono',monospace",
                }}>
                  Copy to Strava
                </button>
                <button onClick={() => setHrShowInstructions(hrShowInstructions === "garmin" ? null : "garmin")} style={{
                  padding: "8px 16px", borderRadius: 6, border: `1px solid ${C.border}`,
                  background: hrShowInstructions === "garmin" ? "#007CC320" : "transparent",
                  color: hrShowInstructions === "garmin" ? "#007CC3" : C.textDim,
                  fontSize: 12, fontWeight: 600, cursor: "pointer", fontFamily: "'IBM Plex Mono',monospace",
                }}>
                  Copy to Garmin
                </button>
                <button
                  disabled={reclassifying}
                  onClick={async () => {
                    setReclassifying(true); setReclassifyResult(null);
                    try {
                      const fn = functions.httpsCallable("reclassifyActivities");
                      const res = await fn({});
                      setReclassifyResult({ ok: true, updated: res.data.updated, zones: res.data.zones });
                      // After re-bucketing historical activities into the
                      // new HR zones, also re-pace the upcoming plan so the
                      // prescribed targetHR / hrZone on each day reflect the
                      // updated zones. requestPlanRefresh flags Plan-tab and
                      // navigates to it; <Plan> picks it up via
                      // consumePlanRefreshRequest and Smart-Refreshes every
                      // non-locked week against the current pace + HR model.
                      if (requestPlanRefresh) requestPlanRefresh();
                    } catch (err) {
                      setReclassifyResult({ ok: false, error: err.message });
                    }
                    setReclassifying(false);
                  }}
                  style={{
                    padding: "8px 16px", borderRadius: 6, border: `1px solid ${C.amber}40`,
                    background: reclassifying ? C.bg3 : "transparent",
                    color: reclassifying ? C.textMuted : C.amber,
                    fontSize: 12, fontWeight: 600, cursor: reclassifying ? "not-allowed" : "pointer",
                    fontFamily: "'IBM Plex Mono',monospace",
                  }}>
                  {reclassifying ? "Reclassifying..." : "Reclassify Activities"}
                </button>
              </div>
              {reclassifyResult && (
                <div style={{ marginTop: 10, padding: "10px 14px", borderRadius: 8,
                  background: reclassifyResult.ok ? C.green + "15" : C.red + "15",
                  fontSize: 12, color: reclassifyResult.ok ? C.green : C.red }}>
                  {reclassifyResult.ok
                    ? `✓ ${reclassifyResult.updated} activities reclassified — refreshing plan to match new zones…`
                    : `Error: ${reclassifyResult.error}`}
                </div>
              )}

              {hrShowInstructions === "strava" && hrResult.stravaInstructions && (
                <div style={{ marginTop: 12, padding: 16, background: C.bg2, borderRadius: 8, fontSize: 12, color: C.textMuted, lineHeight: 1.8, whiteSpace: "pre-wrap" }}>
                  {hrResult.stravaInstructions}
                </div>
              )}
              {hrShowInstructions === "garmin" && hrResult.garminInstructions && (
                <div style={{ marginTop: 12, padding: 16, background: C.bg2, borderRadius: 8, fontSize: 12, color: C.textMuted, lineHeight: 1.8, whiteSpace: "pre-wrap" }}>
                  {hrResult.garminInstructions}
                </div>
              )}
            </Card>
          )}

          {/* Merge Duplicate Activities */}
          <h3 style={{ fontSize: 14, fontWeight: 700, color: C.textDim, marginBottom: 12, marginTop: 32, textTransform: "uppercase", letterSpacing: "0.05em" }}>Merge Duplicates</h3>
          <Card>
            <div style={{ fontSize: 13, fontWeight: 600, color: C.text, marginBottom: 6 }}>Peloton + Garmin Auto-Merge</div>
            <div style={{ fontSize: 12, color: C.textMuted, marginBottom: 16, lineHeight: 1.6 }}>
              Detects treadmill runs recorded by both Peloton and Garmin. Keeps the Peloton activity
              (accurate treadmill pace &amp; laps), copies cadence and running dynamics from Garmin, then deletes the Garmin duplicate.
            </div>
            {mergePairs === null ? (
              <Btn onClick={async () => {
                setMerging(true); setError(null);
                try {
                  const fn = functions.httpsCallable("mergeDuplicateActivities");
                  const res = await fn({ dryRun: true });
                  setMergePairs(res.data.pairs || []);
                } catch(e) { setError(e.message); }
                setMerging(false);
              }} disabled={merging}>
                {merging ? "Scanning..." : "Preview Duplicates"}
              </Btn>
            ) : mergePairs.length === 0 ? (
              <div style={{ fontSize: 12, color: C.green }}>No duplicate pairs found.</div>
            ) : (
              <div>
                <div style={{ fontSize: 12, color: C.textMuted, marginBottom: 10 }}>{mergePairs.length} pair{mergePairs.length !== 1 ? "s" : ""} found — review before merging:</div>
                <div style={{ maxHeight: 220, overflowY: "auto", marginBottom: 12 }}>
                  {mergePairs.map((p, i) => (
                    <div key={i} style={{ padding: "8px 0", borderBottom: `1px solid ${C.border}`, fontSize: 12 }}>
                      <div style={{ color: C.textMuted, fontSize: 10, marginBottom: 3 }}>{p.date}</div>
                      <div style={{ display: "flex", gap: 8, alignItems: "center" }}>
                        <span style={{ color: C.green, fontWeight: 600 }}>KEEP</span>
                        <span style={{ color: C.text }}>{p.keep.name}</span>
                        {p.keep.pace && <span style={{ color: C.amber, fontSize: 10 }}>{Math.floor(p.keep.pace/60)}:{String(p.keep.pace%60).padStart(2,"0")}/mi</span>}
                      </div>
                      <div style={{ display: "flex", gap: 8, alignItems: "center", marginTop: 3 }}>
                        <span style={{ color: C.red, fontWeight: 600 }}>DELETE</span>
                        <span style={{ color: C.textMuted }}>{p.delete.name}</span>
                        {p.garminCadence && <span style={{ color: C.cyan, fontSize: 10 }}>cadence: {Math.round(p.garminCadence * 2)} spm</span>}
                        {p.garminDynamics && <span style={{ color: C.purple, fontSize: 10 }}>+ dynamics</span>}
                        {p.garminDistance && <span style={{ color: C.amber, fontSize: 10 }}>pace from Garmin footpod</span>}
                      </div>
                    </div>
                  ))}
                </div>
                <div style={{ display: "flex", gap: 8 }}>
                  <Btn onClick={async () => {
                    setMerging(true); setError(null);
                    try {
                      const fn = functions.httpsCallable("mergeDuplicateActivities");
                      const res = await fn({ dryRun: false });
                      setSuccess(`Merged ${res.data.merged} duplicate pair${res.data.merged !== 1 ? "s" : ""}.`);
                      setMergePairs(null);
                    } catch(e) { setError(e.message); }
                    setMerging(false);
                  }} disabled={merging}>
                    {merging ? "Merging..." : `Merge ${mergePairs.length} Pair${mergePairs.length !== 1 ? "s" : ""}`}
                  </Btn>
                  <Btn variant="ghost" onClick={() => setMergePairs(null)}>Cancel</Btn>
                </div>
              </div>
            )}
          </Card>

          {/* Daily Summary Email Settings */}
          <h3 style={{ fontSize: 14, fontWeight: 700, color: C.textDim, marginBottom: 12, marginTop: 32, textTransform: "uppercase", letterSpacing: "0.05em" }}>Daily Summary Email</h3>

          <Card style={{ marginBottom: 16 }}>
            <div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
              <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
                <div>
                  <div style={{ fontSize: 14, fontWeight: 600, color: C.text }}>Enable Daily Summary</div>
                  <div style={{ fontSize: 12, color: C.textMuted, marginTop: 2 }}>Receive a performance digest email every morning</div>
                </div>
                <button onClick={() => setSummaryEnabled(!summaryEnabled)} style={{
                  width: 48, height: 26, borderRadius: 13, border: "none",
                  background: summaryEnabled ? C.green : C.bg3,
                  cursor: "pointer", position: "relative", transition: "background 0.2s",
                }}>
                  <div style={{
                    width: 20, height: 20, borderRadius: 10, background: "#fff",
                    position: "absolute", top: 3,
                    left: summaryEnabled ? 25 : 3, transition: "left 0.2s",
                  }} />
                </button>
              </div>

              <div>
                <label style={{ fontSize: 12, color: C.textMuted, display: "block", marginBottom: 6 }}>Email Address</label>
                <input
                  type="email"
                  value={summaryEmail}
                  onChange={e => setSummaryEmail(e.target.value)}
                  placeholder="your@email.com"
                  style={{
                    width: "100%", padding: "10px 14px", borderRadius: 8,
                    border: `1px solid ${C.border}`, background: C.bg2, color: C.text,
                    fontSize: 14, fontFamily: "'IBM Plex Mono',monospace",
                    outline: "none", boxSizing: "border-box",
                  }}
                />
              </div>

              <div style={{ display: "flex", gap: 12, alignItems: "flex-end" }}>
                <div style={{ flex: 1 }}>
                  <label style={{ fontSize: 12, color: C.textMuted, display: "block", marginBottom: 6 }}>Send Time (ET)</label>
                  <input
                    type="time"
                    value={summaryTime}
                    onChange={e => setSummaryTime(e.target.value)}
                    style={{
                      width: "100%", padding: "10px 14px", borderRadius: 8,
                      border: `1px solid ${C.border}`, background: C.bg2, color: C.text,
                      fontSize: 14, fontFamily: "'IBM Plex Mono',monospace",
                      outline: "none", boxSizing: "border-box",
                    }}
                  />
                </div>
                <button onClick={sendTestEmail} disabled={sendingTest || !summaryEmail} style={{
                  padding: "10px 20px", borderRadius: 8, border: "none",
                  background: summaryEmail ? C.cyanMuted : C.bg3,
                  color: summaryEmail ? "#fff" : C.textMuted,
                  fontSize: 13, fontWeight: 600, cursor: summaryEmail ? "pointer" : "not-allowed",
                  fontFamily: "'IBM Plex Mono',monospace", whiteSpace: "nowrap",
                }}>
                  {sendingTest ? "Sending..." : "Daily Test"}
                </button>
                <button onClick={sendWeeklyEmail} disabled={sendingTest || !summaryEmail} style={{
                  padding: "10px 20px", borderRadius: 8, border: "none",
                  background: summaryEmail ? "#7c3aed" : C.bg3,
                  color: summaryEmail ? "#fff" : C.textMuted,
                  fontSize: 13, fontWeight: 600, cursor: summaryEmail ? "pointer" : "not-allowed",
                  fontFamily: "'IBM Plex Mono',monospace", whiteSpace: "nowrap",
                }}>
                  {sendingTest ? "Sending..." : "Weekly Test"}
                </button>
              </div>
            </div>
          </Card>

          {/* Telegram coach bot */}
          <h3 style={{ fontSize: 14, fontWeight: 700, color: C.textDim, marginBottom: 12, marginTop: 32, textTransform: "uppercase", letterSpacing: "0.05em" }}>Telegram</h3>
          <Card style={{ marginBottom: 16, borderColor: "#26a5e425" }}>
            <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 8 }}>
              <div>
                <div style={{ fontSize: 13, fontWeight: 600, color: C.textDim }}>💬 Telegram Coach</div>
                <div style={{ fontSize: 11, color: C.textMuted, marginTop: 4 }}>
                  Chat with your coach and get proactive run / readiness pushes on Telegram
                </div>
              </div>
              {tgLink?.chatId ? (
                <span style={{ fontSize: 10, color: C.green, padding: "3px 10px", background: C.green + "15", borderRadius: 6, fontWeight: 700 }}>
                  Paired
                </span>
              ) : (
                <span style={{ fontSize: 10, color: C.textMuted, padding: "3px 10px", background: C.bg3, borderRadius: 6 }}>
                  Not paired
                </span>
              )}
            </div>

            {!tgLink?.chatId && (
              <div style={{ display: "flex", flexDirection: "column", gap: 10, marginTop: 12 }}>
                <div style={{ fontSize: 12, color: C.textDim, lineHeight: 1.6 }}>
                  <b>Setup:</b> open <a href="https://t.me/BotFather" target="_blank" rel="noreferrer" style={{color:C.cyan}}>@BotFather</a>, create a bot, and add its token to the <code>TELEGRAM_BOT_TOKEN</code> repo secret. Then:
                </div>
                <ol style={{ fontSize: 12, color: C.textDim, margin: 0, paddingLeft: 20, lineHeight: 1.7 }}>
                  <li>Click <b>Generate link code</b> below.</li>
                  <li>Open your bot in Telegram and send <code>/start CODE</code> within 15 minutes.</li>
                  <li>The bot confirms pairing. Ask it anything, or use <code>/readiness</code>, <code>/status</code>, <code>/help</code>.</li>
                </ol>
                <div>
                  <Btn onClick={tgGenerateCode} disabled={tgGenerating} variant="primary" small>
                    {tgGenerating ? "Generating…" : "Generate link code"}
                  </Btn>
                </div>
                {tgLink?.linkCode && (
                  <div style={{ padding: "10px 14px", background: C.cyan + "10", border: `1px solid ${C.cyan}40`, borderRadius: 8, fontSize: 12, color: C.text }}>
                    <div style={{ fontFamily: "'IBM Plex Mono',monospace", fontSize: 18, fontWeight: 700, letterSpacing: "0.1em", color: C.cyan }}>
                      /start {tgLink.linkCode}
                    </div>
                    <div style={{ fontSize: 10, color: C.textMuted, marginTop: 4 }}>
                      Send this to your bot in Telegram within 15 minutes.
                    </div>
                  </div>
                )}
              </div>
            )}

            {tgLink?.chatId && (
              <div style={{ display: "flex", flexDirection: "column", gap: 10, marginTop: 12 }}>
                <div style={{ fontSize: 12, color: C.textDim }}>
                  Paired with <b>{tgLink.displayName || "Telegram"}</b>
                  {tgLink.linkedAt && ` on ${new Date(tgLink.linkedAt).toLocaleDateString()}`}.
                </div>
                <div style={{ fontSize: 11, color: C.textMuted, fontWeight: 700, marginTop: 4 }}>NOTIFICATIONS</div>
                {[
                  { key: "runCompleted", label: "Run completed", desc: "Summary pushed after every Strava run sync" },
                  { key: "readinessLow",  label: "Low readiness alert", desc: "Ping when Garmin readiness < 40" },
                  { key: "planChanges",   label: "Plan changes", desc: "Notify on plan regeneration" },
                  { key: "dailySummary",  label: "Daily summary", desc: "Morning push matching the email" },
                ].map(row => {
                  const on = tgLink.notifications?.[row.key] !== false;
                  return (
                    <div key={row.key} style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 12 }}>
                      <div style={{ flex: 1 }}>
                        <div style={{ fontSize: 12, color: C.text }}>{row.label}</div>
                        <div style={{ fontSize: 10, color: C.textMuted }}>{row.desc}</div>
                      </div>
                      <button onClick={() => tgSetNotif(row.key, !on)} style={{
                        width: 36, height: 20, borderRadius: 10, border: "none",
                        background: on ? C.cyan : C.bg3, cursor: "pointer", position: "relative", flexShrink: 0,
                      }}>
                        <div style={{
                          width: 14, height: 14, borderRadius: 7, background: "#fff",
                          position: "absolute", top: 3,
                          left: on ? 19 : 3, transition: "left 0.2s",
                        }} />
                      </button>
                    </div>
                  );
                })}
                <button onClick={tgUnlink} style={{
                  alignSelf: "flex-start", marginTop: 8, background: "none",
                  border: `1px solid ${C.red}40`, color: C.red, borderRadius: 6,
                  padding: "6px 14px", cursor: "pointer", fontSize: 11, fontFamily: "'IBM Plex Mono',monospace",
                }}>Unlink</button>
              </div>
            )}

            {tgMsg && (
              <div style={{
                marginTop: 10, padding: "8px 12px", borderRadius: 6, fontSize: 11,
                background: tgMsg.type === "ok" ? C.green + "15" : C.red + "15",
                color: tgMsg.type === "ok" ? C.green : C.red,
                border: `1px solid ${tgMsg.type === "ok" ? C.green : C.red}40`,
              }}>
                {tgMsg.text}
              </div>
            )}
          </Card>

          <Card style={{ marginTop: 16 }}>
            <h3 style={{ fontSize: 14, fontWeight: 700, color: C.textDim, marginBottom: 8 }}>Merge Priority</h3>
            <div style={{ fontSize: 12, color: C.textMuted, lineHeight: 1.8 }}>
              <div>1. <strong style={{color:C.text}}>Manual overrides</strong> — your edits always win</div>
              <div>2. <strong style={{color:C.green}}>Garmin Connect</strong> — deepest health metrics (HRV, Body Battery, stress, SpO2, training readiness)</div>
              <div>4. <strong style={{color:C.amber}}>Strava</strong> — activity-specific data (pace, route, effort)</div>
            </div>
          </Card>
        </div>
      );
    }

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