/******************************************************************** * Oura Stats + Regression‑based Trend & Δ % * * – zebra‑striped rows for easier scanning * ********************************************************************/ /* ---------- helpers ---------- */ function fmtNum(n, d = 0) { return n.toFixed(d).replace(/\B(?=(\d{3})+(?!\d))/g, ","); } function fmtDate(s) { const [y, m, d] = s.split("-"); return `${parseInt(m)}/${parseInt(d)}`; } function linReg(x, y) { const n = x.length, sx = x.reduce((a, b) => a + b, 0), sy = y.reduce((a, b) => a + b, 0), sxy = x.reduce((a, _, i) => a + x[i] * y[i], 0), sxx = x.reduce((a, v) => a + v * v, 0); const den = n * sxx - sx * sx; if (!den) return { a: 0, b: 0 }; const b = (n * sxy - sx * sy) / den; const a = (sy - b * sx) / n; return { a, b }; } /* ---------- metric config ---------- */ const metrics = [ { k: "ActivityScore", label: "Activity Score", better: "high" }, { k: "ActivitySteps", label: "Steps", better: "high" }, { k: "ActivityTotalCalories", label: "Calories", better: "high" }, { k: "BodyBodyTemperature", label: "Body Temp", better: "low", dec: 1 }, { k: "BodyReadinessScore", label: "Readiness Score", better: "high" }, { k: "BodyRestingHeartRate", label: "Resting HR", better: "low" }, { k: "SleepREM", label: "REM Time (min)", better: "high" }, { k: "SleepScore", label: "Sleep Score", better: "high" } ].sort((a, b) => a.label.localeCompare(b.label)); /* ---------- time‑range dropdown ---------- */ const spans = [ { v: "1w", lbl: "1 Week", since: dv.date("today").minus({ weeks: 1 }) }, { v: "2w", lbl: "2 Weeks", since: dv.date("today").minus({ weeks: 2 }) }, { v: "1m", lbl: "1 Month", since: dv.date("today").minus({ months: 1 }) }, { v: "6w", lbl: "6 Weeks", since: dv.date("today").minus({ weeks: 6 }) }, { v: "2m", lbl: "2 Months",since: dv.date("today").minus({ months: 2 }) }, { v: "90d", lbl: "90 Days", since: dv.date("today").minus({ days: 90 }) }, { v: "all", lbl: "All Time",since: dv.date("1900‑01‑01") } ]; /* ---------- UI ---------- */ dv.paragraph(`
`); /* ---------- core calc ---------- */ function calcStats(vals, dates, key) { let rows = []; for (let i = 0; i < vals.length; i++) if (typeof vals[i] === "number" && !isNaN(vals[i])) rows.push({ v: vals[i], d: dates[i] }); if (key === "BodyRestingHeartRate") rows = rows.filter(r => r.v >= 25); if (key === "BodyBodyTemperature") rows = rows.filter(r => r.v >= 85); if (!rows.length) return { min: "N/A", max: "N/A", avg: "N/A", last: "N/A", trend: "—", delta: "—" }; rows.sort((a, b) => new Date(a.d) - new Date(b.d)); // oldest → newest let min = rows[0], max = rows[0], sum = 0; for (const r of rows) { if (r.v < min.v) min = r; if (r.v > max.v) max = r; sum += r.v; } const dec = key === "BodyBodyTemperature" ? 1 : 0; const avg = sum / rows.length; const last = rows[rows.length - 1]; const t0 = new Date(rows[0].d).getTime(); const xs = rows.map(r => (new Date(r.d).getTime() - t0) / 8.64e7); // days since start const ys = rows.map(r => r.v); const { a, b } = linReg(xs, ys); const firstPred = a; const lastPred = a + b * xs[xs.length - 1]; const changePct = ((lastPred - firstPred) / firstPred) * 100; const cfg = metrics.find(m => m.k === key); const upGood = cfg.better === "high"; const flatBand = 1.0; // ±1 % = flat band let arrow, color; if (Math.abs(changePct) < flatBand) { arrow = "➡"; color = "darkgray"; } else { const goingUp = changePct > 0; arrow = goingUp ? "▲" : "▼"; const good = upGood ? goingUp : !goingUp; color = good ? "limegreen" : "crimson"; } return { min: `${fmtNum(min.v, dec)} (${fmtDate(min.d)})`, max: `${fmtNum(max.v, dec)} (${fmtDate(max.d)})`, avg: fmtNum(avg, dec), last: fmtNum(last.v, dec), trend:`${arrow}`, delta:`${changePct >= 0 ? "+" : ""}${fmtNum(changePct, 1)}%` }; } /* ---------- renderer ---------- */ function drawTable() { const spanVal = dv.container.querySelector("#rangeSel").value; const since = spans.find(s => s.v === spanVal).since; const files = dv.pages('"Body/Oura"') .where(p => p.file.name.match(/^\d{4}-\d{2}-\d{2}$/)) .where(p => dv.date(p.file.name) >= since) .sort(p => p.file.name, 'desc'); const rows = metrics.map(m => { const vals = files.map(f => f[m.k]); const dates = files.map(f => f.file.name); const s = calcStats(vals, dates, m.k); return [m.label, s.min, s.max, s.avg, s.last, s.trend, s.delta]; }); const headers = ["Variable", "Min", "Max", "Average", "Last Value", "Trend", "Δ %"]; const html = ` ${headers.map(h => ``).join("")} ${rows.map((r, i) => { const bg = i % 2 === 0 ? "rgba(255,255,255,0.05)" : "transparent"; return ` ${r.map(c => ``).join("")} `; }).join("")}
${h}
${c}
`; dv.container.querySelector("#tblWrap").innerHTML = html; } drawTable(); dv.container.querySelector("#updBtn").addEventListener("click", drawTable);