// Data layer — auth, API calls, and transformations from API shape to the // design's SERIES contract (raw / avg / std / target / color / unit / invert). // ── Auth ──────────────────────────────────────────────────────────── const TOKEN_KEY = 'hc_token'; // iOS PWA gotcha: "Add to Home Screen" creates a standalone webview with a // separate localStorage from Safari. If we strip the token from the URL on // first load, the user's add-to-home-screen capture loses the token and the // installed icon launches token-less. Keep the token in the URL — single-user // personal tool, the URL is private. Worst case (paste a screenshot of the // URL bar publicly) is no worse than leaking the token anyway. function loadToken() { const params = new URLSearchParams(window.location.search); const fromUrl = params.get('token'); if (fromUrl) { try { window.localStorage.setItem(TOKEN_KEY, fromUrl); } catch (e) {} return fromUrl; } try { return window.localStorage.getItem(TOKEN_KEY) || null; } catch (e) { return null; } } function saveToken(token) { if (!token) return; try { window.localStorage.setItem(TOKEN_KEY, token); } catch (e) {} window.location.reload(); } let TOKEN = loadToken(); async function api(path, opts = {}) { const headers = { 'Content-Type': 'application/json', ...(opts.headers || {}), }; if (TOKEN) headers['Authorization'] = `Bearer ${TOKEN}`; const res = await fetch(path, { ...opts, headers }); if (!res.ok) { let detail; try { detail = await res.json(); } catch (_) { detail = await res.text(); } const err = new Error(`api ${path} -> ${res.status}`); err.status = res.status; err.detail = detail; throw err; } return res.json(); } // ── Math helpers ──────────────────────────────────────────────────── // Forward-fill nulls so the spark stays continuous. If a leading value is // null, replace it with the first non-null. If the whole array is null, // returns zeros — caller is responsible for treating that as no-data. function fillNulls(arr) { const out = arr.slice(); let prev = null; for (let i = 0; i < out.length; i++) { if (out[i] == null) out[i] = prev; else prev = out[i]; } // Back-fill leading nulls with the first known. let first = out.find((v) => v != null); if (first == null) return out.map(() => 0); for (let i = 0; i < out.length; i++) if (out[i] == null) out[i] = first; return out; } function rollingMean(arr, win = 10) { return arr.map((_, i) => { const start = Math.max(0, i - win + 1); const slice = arr.slice(start, i + 1); return slice.reduce((s, v) => s + v, 0) / slice.length; }); } function rollingStd(arr, win = 10) { return arr.map((_, i) => { const start = Math.max(0, i - win + 1); const slice = arr.slice(start, i + 1); const mean = slice.reduce((s, v) => s + v, 0) / slice.length; const variance = slice.reduce((s, v) => s + (v - mean) ** 2, 0) / slice.length; return Math.sqrt(variance); }); } // 5-day rolling sum, capped at 5, gives a "sessions / wk" proxy similar to // the design's synthetic training series. function trainingSeries(days) { const flags = days.map((d) => (d.has_run ? 1 : 0) + (d.has_strength ? 1 : 0)); return flags.map((_, i) => { const start = Math.max(0, i - 6); const slice = flags.slice(start, i + 1); return slice.reduce((a, b) => a + b, 0); }); } // ── SERIES builder ────────────────────────────────────────────────── // Bedtime is stored on the API as decimal hours, +24 if after midnight // (so 22:45 = 22.75, 00:15 = 24.25). We render the chart in that scale, // then format ticks back to HH:MM via fmtBedtime. const PALETTE = window.HC_THEME.PALETTE; function buildSeries(days) { if (!days || !days.length) return null; const sleep_raw = fillNulls(days.map((d) => d.sleep_h)); const hrv_raw = fillNulls(days.map((d) => d.hrv)); const hr_raw = fillNulls(days.map((d) => d.avg_hr)); const ready_raw = fillNulls(days.map((d) => d.readiness)); const bedtime_raw = fillNulls(days.map((d) => d.bedtime_decimal)); const train_raw = trainingSeries(days); return { sleep: { raw: sleep_raw, avg: rollingMean(sleep_raw), std: rollingStd(sleep_raw), target: 6.5, color: PALETTE.sleep, unit: 'h', }, hrv: { raw: hrv_raw, avg: rollingMean(hrv_raw), std: rollingStd(hrv_raw), target: 25, color: PALETTE.hrv, unit: 'ms', }, hr: { raw: hr_raw, avg: rollingMean(hr_raw), std: rollingStd(hr_raw), target: null, color: PALETTE.hr, unit: 'bpm', invert: true, }, ready: { raw: ready_raw, avg: rollingMean(ready_raw), std: rollingStd(ready_raw), target: 70, color: PALETTE.ready, unit: '', }, bedtime: { raw: bedtime_raw, avg: rollingMean(bedtime_raw), std: rollingStd(bedtime_raw), target: 22.75, color: PALETTE.sleep, unit: 'h', }, training: { raw: train_raw, avg: rollingMean(train_raw, 7), std: rollingStd(train_raw, 7), target: null, color: PALETTE.training, unit: '/wk', }, // Drinks per day (units). 0 means a true zero (no bar should render), // not null/missing — backend already collapses both into 0. drinks: (() => { const raw = days.map((d) => d.drinks == null ? 0 : Number(d.drinks)); return { raw, avg: rollingMean(raw, 7), std: rollingStd(raw, 7), target: 3, color: PALETTE.warning, unit: 'u', }; })(), }; } // Format decimal hour (with +24 if after midnight) as HH:MM. function fmtBedtime(decHour) { if (decHour == null) return '--:--'; const totalMin = Math.round(decHour * 60); const h = Math.floor(totalMin / 60) % 24; const m = ((totalMin % 60) + 60) % 60; return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`; } // Split a days array into three equal phases for the Peak/Decline/Recovery // summary cards. Real data doesn't carry phase labels — we approximate by // thirds so the visual contract is preserved without needing user input. function thirdsPhases(days) { const n = days ? days.length : 0; const a = Math.floor(n / 3); const b = Math.floor((2 * n) / 3); return [ { name: 'Early', range: [0, a], color: 'rgba(58,125,92,0.05)' }, { name: 'Mid', range: [a, b], color: 'rgba(192,57,43,0.05)' }, { name: 'Recent', range: [b, n], color: 'rgba(46,111,173,0.05)' }, ]; } function phaseAvg(series, phase) { const [a, b] = phase.range; if (!series || a >= b) return NaN; const slice = series.raw.slice(a, b); if (!slice.length) return NaN; return slice.reduce((s, v) => s + v, 0) / slice.length; } // Format a delta value with arrow + sign. positiveGood=true means up is good. function fmtDelta(v, decimals = 1, positiveGood = true) { if (v == null || isNaN(v)) return ''; const abs = Math.abs(v).toFixed(decimals); const arrow = v >= 0 ? '↑' : '↓'; return `${arrow} ${abs}`; } function fmtHoursMinutes(hours) { if (hours == null) return '--'; const totalMin = Math.round(hours * 60); const h = Math.floor(totalMin / 60); const m = totalMin % 60; return `${h}h${String(m).padStart(2, '0')}m`; } // Map ISO date -> integer index in the days array, for event annotations. function eventsToAnnotations(events, days) { if (!events || !days) return []; const idx = new Map(); days.forEach((d, i) => idx.set(d.day, i)); return events .map((e) => ({ idx: idx.get(e.date), label: e.label, category: e.category })) .filter((e) => e.idx != null); } window.HC_DATA = { TOKEN, api, saveToken, buildSeries, fmtBedtime, fmtDelta, fmtHoursMinutes, thirdsPhases, phaseAvg, eventsToAnnotations, rollingMean, rollingStd, };