diff --git a/internal/api/health.go b/internal/api/health.go index 96bb00b..2db4bc4 100644 --- a/internal/api/health.go +++ b/internal/api/health.go @@ -183,6 +183,8 @@ func healthRecordFromElement(metricType string, el json.RawMessage) (models.Heal switch metricType { case "steps": setScalar(&r, m, "count", "steps") + case "active_calories": + setScalar(&r, m, "calories", "calories") case "heart_rate", "resting_heart_rate": setScalar(&r, m, "bpm", "bpm") case "sleep", "exercise": diff --git a/internal/models/models.go b/internal/models/models.go index 95a3272..3e53a2a 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -60,7 +60,7 @@ type Set struct { Source string `db:"SOURCE" json:"Source"` // "manual" | "cv" Confidence float64 `db:"CONFIDENCE" json:"Confidence"` // 0.0–1.0 Pending bool `db:"PENDING" json:"Pending"` - ClipPath string `db:"CLIP_PATH" json:"ClipPath"` // path under PUMP_CLIPS_DIR, served at /clips/<...> + ClipPath string `db:"CLIP_PATH" json:"ClipPath"` // path under PUMP_CLIPS_DIR, served at /clips/<...> } // SetUpdate - partial update for one set. Only non-nil fields are applied. @@ -153,12 +153,13 @@ type CardioSession struct { // active store has no HealthStore (split-frontend mode) — the UI then shows // empty states instead of failing. type HealthStats struct { - Available bool `json:"Available"` - DailySteps []DayValue `json:"DailySteps"` - RestingHR []DayValue `json:"RestingHR"` - DailyHR []DayRange `json:"DailyHR"` - Sleep []SleepNight `json:"Sleep"` - Cardio []CardioSession `json:"Cardio"` + Available bool `json:"Available"` + DailySteps []DayValue `json:"DailySteps"` + RestingHR []DayValue `json:"RestingHR"` + DailyHR []DayRange `json:"DailyHR"` + Sleep []SleepNight `json:"Sleep"` + Cardio []CardioSession `json:"Cardio"` + ActiveCalories []DayValue `json:"ActiveCalories"` // daily active-energy total (kcal) } // GuiData - web gui data diff --git a/internal/web/healthstats.go b/internal/web/healthstats.go index 5ee7970..2225906 100644 --- a/internal/web/healthstats.go +++ b/internal/web/healthstats.go @@ -41,6 +41,7 @@ func aggregateHealth(recs []models.HealthRecord) models.HealthStats { out := models.HealthStats{Available: true} stepsByDay := map[string]float64{} + caloriesByDay := map[string]float64{} restingByDay := map[string][]float64{} hrByDay := map[string][]float64{} sleepByDay := map[string]*models.SleepNight{} @@ -58,6 +59,8 @@ func aggregateHealth(recs []models.HealthRecord) models.HealthStats { switch r.MetricType { case "steps": stepsByDay[day] += val(r) + case "active_calories": + caloriesByDay[day] += val(r) case "resting_heart_rate": restingByDay[day] = append(restingByDay[day], val(r)) case "heart_rate": @@ -83,18 +86,29 @@ func aggregateHealth(recs []models.HealthRecord) models.HealthStats { for d, v := range stepsByDay { out.DailySteps = append(out.DailySteps, models.DayValue{Date: d, Value: v}) } - for d, vs := range restingByDay { - out.RestingHR = append(out.RestingHR, models.DayValue{Date: d, Value: mean(vs)}) + for d, v := range caloriesByDay { + out.ActiveCalories = append(out.ActiveCalories, models.DayValue{Date: d, Value: v}) } for d, vs := range hrByDay { mn, av, mx := minAvgMax(vs) out.DailyHR = append(out.DailyHR, models.DayRange{Date: d, Min: mn, Avg: av, Max: mx}) + // Resting HR proxy: when a source only exports raw heart_rate (no + // dedicated resting_heart_rate samples), use the day's minimum as a + // resting estimate so the Resting HR tile/line aren't perpetually + // blank. A real resting_heart_rate record for the day always wins. + if _, ok := restingByDay[d]; !ok { + restingByDay[d] = []float64{mn} + } + } + for d, vs := range restingByDay { + out.RestingHR = append(out.RestingHR, models.DayValue{Date: d, Value: mean(vs)}) } for _, n := range sleepByDay { out.Sleep = append(out.Sleep, *n) } sortByDate(out.DailySteps) + sortByDate(out.ActiveCalories) sortByDate(out.RestingHR) sort.Slice(out.DailyHR, func(i, j int) bool { return out.DailyHR[i].Date < out.DailyHR[j].Date }) sort.Slice(out.Sleep, func(i, j int) bool { return out.Sleep[i].Date < out.Sleep[j].Date }) @@ -137,8 +151,12 @@ func minAvgMax(vs []float64) (mn, av, mx float64) { } // addSleepStages parses a sleep record's Extra ({"stages":[{stage,duration_seconds}]}) -// and accumulates per-stage minutes onto n. Unknown stage labels are ignored -// (their time still counts toward Minutes from the session duration). +// and accumulates per-stage minutes onto n. Unknown stages are ignored (their +// time still counts toward Minutes from the session duration). +// +// Health Connect serialises the stage as the integer SleepSessionRecord.Stage +// constant (e.g. "4", "5", "6"), not a label, so we map both the numeric codes +// and the human labels — different export bridges emit one or the other. func addSleepStages(extra json.RawMessage, n *models.SleepNight) { if len(extra) == 0 { return @@ -155,13 +173,13 @@ func addSleepStages(extra json.RawMessage, n *models.SleepNight) { for _, s := range e.Stages { m := s.DurationSeconds / 60.0 switch s.Stage { - case "DEEP": + case "DEEP", "5": n.Deep += m - case "LIGHT": + case "LIGHT", "SLEEPING", "4", "2": n.Light += m - case "REM": + case "REM", "6": n.REM += m - case "AWAKE", "OUT_OF_BED", "AWAKE_IN_BED": + case "AWAKE", "OUT_OF_BED", "AWAKE_IN_BED", "1", "3", "7": n.Awake += m } } diff --git a/internal/web/healthstats_test.go b/internal/web/healthstats_test.go new file mode 100644 index 0000000..833b19f --- /dev/null +++ b/internal/web/healthstats_test.go @@ -0,0 +1,107 @@ +package web + +import ( + "encoding/json" + "testing" + "time" + + "github.com/shopspring/decimal" + + "github.com/rwlove/PUMP/internal/models" +) + +// rec is a small helper to build a HealthRecord with a decimal Value. +func rec(metric string, start time.Time, value float64, extra string) models.HealthRecord { + d := decimal.NewFromFloat(value) + end := start + r := models.HealthRecord{ + MetricType: metric, + StartTime: start, + EndTime: &end, + Value: &d, + } + if extra != "" { + r.Extra = json.RawMessage(extra) + } + return r +} + +// TestAggregateHealthSleepStagesNumeric verifies that Health Connect's numeric +// stage codes (4=LIGHT, 5=DEEP, 6=REM) are decoded — the bug that left Deep +// sleep perpetually at zero. Mirrors the real on-device payload shape. +func TestAggregateHealthSleepStagesNumeric(t *testing.T) { + day := time.Date(2026, 6, 9, 23, 0, 0, 0, time.Local) + extra := `{"stages":[ + {"stage":"5","duration_seconds":3600}, + {"stage":"4","duration_seconds":1200}, + {"stage":"6","duration_seconds":600}, + {"stage":"1","duration_seconds":300} + ],"duration_seconds":5700}` + + out := aggregateHealth([]models.HealthRecord{rec("sleep", day, 5700, extra)}) + if len(out.Sleep) != 1 { + t.Fatalf("want 1 sleep night, got %d", len(out.Sleep)) + } + n := out.Sleep[0] + if n.Deep != 60 { + t.Errorf("Deep: want 60 min (stage 5), got %v", n.Deep) + } + if n.Light != 20 { + t.Errorf("Light: want 20 min (stage 4), got %v", n.Light) + } + if n.REM != 10 { + t.Errorf("REM: want 10 min (stage 6), got %v", n.REM) + } + if n.Awake != 5 { + t.Errorf("Awake: want 5 min (stage 1), got %v", n.Awake) + } +} + +// TestAggregateHealthRestingHRProxy verifies that when only raw heart_rate is +// present (no resting_heart_rate), a resting proxy is synthesized from the +// day's minimum so the Resting HR tile/line populate. +func TestAggregateHealthRestingHRProxy(t *testing.T) { + d := time.Date(2026, 6, 7, 10, 0, 0, 0, time.Local) + out := aggregateHealth([]models.HealthRecord{ + rec("heart_rate", d, 85, ""), + rec("heart_rate", d.Add(time.Minute), 70, ""), + rec("heart_rate", d.Add(2*time.Minute), 100, ""), + }) + if len(out.RestingHR) != 1 { + t.Fatalf("want 1 resting proxy day, got %d", len(out.RestingHR)) + } + if out.RestingHR[0].Value != 70 { + t.Errorf("resting proxy: want 70 (daily min), got %v", out.RestingHR[0].Value) + } + if len(out.DailyHR) != 1 || out.DailyHR[0].Max != 100 || out.DailyHR[0].Min != 70 { + t.Errorf("DailyHR spread wrong: %+v", out.DailyHR) + } +} + +// TestAggregateHealthRestingHRRealWins verifies a real resting_heart_rate +// record takes precedence over the heart_rate-min proxy on the same day. +func TestAggregateHealthRestingHRRealWins(t *testing.T) { + d := time.Date(2026, 6, 7, 10, 0, 0, 0, time.Local) + out := aggregateHealth([]models.HealthRecord{ + rec("heart_rate", d, 70, ""), + rec("resting_heart_rate", d, 55, ""), + }) + if len(out.RestingHR) != 1 || out.RestingHR[0].Value != 55 { + t.Errorf("real resting_heart_rate should win (55), got %+v", out.RestingHR) + } +} + +// TestAggregateHealthActiveCalories verifies active_calories sums per day. +func TestAggregateHealthActiveCalories(t *testing.T) { + d := time.Date(2026, 6, 8, 0, 0, 0, 0, time.Local) + out := aggregateHealth([]models.HealthRecord{ + rec("active_calories", d, 174, `{"calories":174.0}`), + rec("active_calories", d.Add(time.Hour), 89, `{"calories":89.0}`), + }) + if len(out.ActiveCalories) != 1 { + t.Fatalf("want 1 active-calorie day, got %d", len(out.ActiveCalories)) + } + if out.ActiveCalories[0].Value != 263 { + t.Errorf("active calories: want 263 (174+89), got %v", out.ActiveCalories[0].Value) + } +} diff --git a/internal/web/public/js/health-charts.js b/internal/web/public/js/health-charts.js index 44dca79..27fcd34 100644 --- a/internal/web/public/js/health-charts.js +++ b/internal/web/public/js/health-charts.js @@ -4,7 +4,7 @@ // reuses filterByPeriod() from stats.js when present (Stats page); the // dashboard uses fixed recent windows and works standalone. -var _stepsChart = null, _hrChart = null, _sleepChart = null, _cardioChart = null; +var _stepsChart = null, _hrChart = null, _sleepChart = null, _cardioChart = null, _calChart = null; var _hdSparks = {}; // ─── helpers ──────────────────────────────────────────────────────────────── @@ -192,6 +192,8 @@ function renderSleepTab(period) { // ─── Cardio tab ───────────────────────────────────────────────────────────── function renderCardioTab(period) { + renderActiveCalories(period); + const data = hcByPeriod(hcData('Cardio'), period); hcSetText('cardio-count', data.length); hcSetText('cardio-mins', Math.round(data.reduce((a, b) => a + b.Minutes, 0))); @@ -226,6 +228,36 @@ function renderCardioTab(period) { } } +// renderActiveCalories draws the daily active-energy chart + tiles on the +// Cardio tab. Separate from cardio sessions: this is daily total active kcal +// (Health Connect ActiveCaloriesBurned), present even when no workout sessions +// are recorded. +function renderActiveCalories(period) { + const all = hcData('ActiveCalories'); + const todayRow = all.find(d => d.Date === window._serverDate); + hcSetText('cal-today', todayRow ? Math.round(todayRow.Value).toLocaleString() : '–'); + + const data = hcByPeriod(all, period); + hcSetText('cal-avg', data.length ? Math.round(hcMean(data.map(d => d.Value))).toLocaleString() : '–'); + + const canvas = document.getElementById('cal-chart'); + const noData = document.getElementById('cal-no-data'); + if (!data.length) { + if (canvas) canvas.style.display = 'none'; + if (noData) noData.style.display = 'block'; + _calChart = hcDestroy(_calChart); + return; + } + if (canvas) canvas.style.display = ''; if (noData) noData.style.display = 'none'; + + _calChart = hcDestroy(_calChart); + _calChart = new Chart(canvas, { + type: 'bar', + data: { labels: data.map(d => d.Date), datasets: [{ label: 'Active kcal', data: data.map(d => Math.round(d.Value)), backgroundColor: '#fd7e14cc', borderRadius: 4 }] }, + options: { responsive: true, scales: { x: hcAxisOpts.x, y: hcAxisOpts.yCount('kcal') }, plugins: { legend: { display: false } } } + }); +} + // ─── Overall Health dashboard ───────────────────────────────────────────────── function renderHealthDashboard() { @@ -249,6 +281,16 @@ function renderHealthDashboard() { hcSpark('hd-steps-spark', hcLast(steps, 30).map(d => d.Value)); } else { hcSetText('hd-steps-val', '–'); } + // Active calories today + 30-day sparkline. + const cals = hcData('ActiveCalories'); + if (cals.length) { + const todayRow = cals.find(d => d.Date === window._serverDate); + hcSetText('hd-cal-val', todayRow ? Math.round(todayRow.Value).toLocaleString() : '0'); + const avg7 = hcMean(hcLast(cals, 7).map(d => d.Value)); + hcDelta('hd-cal-delta', todayRow ? todayRow.Value : 0, avg7, false, ''); + hcSpark('hd-cal-spark', hcLast(cals, 30).map(d => d.Value), '#fd7e14'); + } else { hcSetText('hd-cal-val', '–'); } + // Resting HR. const rhr = hcData('RestingHR'); if (rhr.length) { diff --git a/internal/web/public/js/stats.js b/internal/web/public/js/stats.js index 1a39329..79d90f1 100644 --- a/internal/web/public/js/stats.js +++ b/internal/web/public/js/stats.js @@ -90,7 +90,7 @@ function setGlobalPeriod(period) { updateRecoveryTab(window.currentSets, window.exercises, period); } - // Refresh Consistency stat boxes if visible (heatmap stays year-fixed for context) + // Refresh Consistency if visible (stat boxes + heatmap both scale to the period) const consistencyTab = document.getElementById('tab-consistency'); if (consistencyTab && consistencyTab.classList.contains('show')) { updateConsistencyTab(window.currentSets, period); @@ -635,6 +635,18 @@ function renderOverloadChart(allSets, exercises, period, exerciseName) { var _balanceChart = null; +// Muscle-balance metric: 'sets' (default — number of sets per group) or +// 'volume' (weight × reps). Toggled by the Sets/Volume buttons on the tab. +var balanceMetric = 'sets'; + +function setBalanceMetric(metric) { + balanceMetric = (metric === 'volume') ? 'volume' : 'sets'; + document.querySelectorAll('#tab-balance [data-metric]').forEach(b => { + b.classList.toggle('active', b.dataset.metric === balanceMetric); + }); + updateBalanceTab(window.currentSets, window.exercises, currentPeriod); +} + function updateBalanceTab(allSets, exercises, period) { const filtered = filterByPeriod(allSets, period); const exMap = {}; @@ -671,13 +683,24 @@ function updateBalanceTab(allSets, exercises, period) { const primaryColor = window._chartColor || '#2780e3'; const labelColor = getComputedStyle(document.documentElement).getPropertyValue('--bs-body-color').trim() || '#e0e0e0'; + const bySets = balanceMetric === 'sets'; + const noun = bySets ? 'Sets' : 'Volume'; + const metricVal = g => bySets ? (groupSets[g] || 0) : Math.round(groupVolume[g] || 0); + const fmtSets = g => `${groupSets[g] || 0} set${(groupSets[g] || 0) === 1 ? '' : 's'}`; + const fmtVol = g => groupVolume[g] ? `${Math.round(groupVolume[g]).toLocaleString()} lbs` : '— lbs'; + + const titleEl = document.getElementById('balance-title'); + if (titleEl) titleEl.textContent = `${noun} by Muscle Group`; + const breakdownEl = document.getElementById('balance-breakdown-title'); + if (breakdownEl) breakdownEl.textContent = `${noun} Breakdown`; + _balanceChart = new Chart(ctx, { type: 'radar', data: { labels: groups, datasets: [{ - label: 'Volume', - data: groups.map(g => Math.round(groupVolume[g] || 0)), + label: noun, + data: groups.map(metricVal), backgroundColor: primaryColor + '33', borderColor: primaryColor, borderWidth: 2, @@ -702,11 +725,10 @@ function updateBalanceTab(allSets, exercises, period) { callbacks: { label(ctx) { const g = ctx.label; - const sets = groupSets[g] || 0; - const vol = ctx.raw - ? `${ctx.raw.toLocaleString()} lbs` - : '— lbs'; - return `${vol} · ${sets} set${sets === 1 ? '' : 's'}`; + // Lead with the selected metric; show the other as context. + return bySets + ? `${fmtSets(g)} · ${fmtVol(g)}` + : `${fmtVol(g)} · ${fmtSets(g)}`; } } } @@ -717,14 +739,13 @@ function updateBalanceTab(allSets, exercises, period) { const summaryEl = document.getElementById('balance-summary'); if (summaryEl) { summaryEl.innerHTML = groups.map(g => { - const vol = groupVolume[g] - ? `${Math.round(groupVolume[g]).toLocaleString()} lbs` - : '— lbs'; + const primary = bySets ? fmtSets(g) : fmtVol(g); + const secondary = bySets ? fmtVol(g) : fmtSets(g); return `
${escapeHtml(g)}
-
${groupSets[g]} set${groupSets[g] === 1 ? '' : 's'}
-
${vol}
+
${primary}
+
${secondary}
`; }).join(''); @@ -748,8 +769,8 @@ function updateConsistencyTab(allSets, period) { const todayStr = getTodayStr(); const today = parseDateStr(todayStr); - // All-time set of workout days (used by the heatmap, which always shows - // a fixed 1-year window for visual context). + // All-time set of workout days. The heatmap colours cells from this map; + // the visible window is scoped to the selected period below. const workoutDatesAll = {}; allSets.forEach(s => { workoutDatesAll[s.Date] = (workoutDatesAll[s.Date] || 0) + 1; }); @@ -830,12 +851,16 @@ function updateConsistencyTab(allSets, period) { const { r: pr, g: pg, b: pb } = hexToRgb(window._chartColor || '#2780e3'); - // Grid starts on the Sunday on or before (today - 364 days) - const gridStart = new Date(today); - gridStart.setDate(today.getDate() - 364); + // Window the heatmap to the selected period (previously fixed at 1 year). + // gridStart is the Sunday on or before the period's first day; the grid + // spans whole week-columns from there through the week containing today. + const windowStart = new Date(today); + windowStart.setDate(today.getDate() - (periodDays - 1)); + const windowStartStr = windowStart.toLocaleDateString('en-CA'); + const gridStart = new Date(windowStart); gridStart.setDate(gridStart.getDate() - gridStart.getDay()); // back to Sunday - const numWeeks = 53; + const numWeeks = Math.floor((today - gridStart) / (7 * 86400000)) + 1; const inner = document.createElement('div'); inner.className = 'heatmap-inner'; @@ -890,20 +915,24 @@ function updateConsistencyTab(allSets, period) { const ds = cellDate.toLocaleDateString('en-CA'); const count = workoutDatesAll[ds] || 0; const isFuture = ds > todayStr; + // Days in the leading partial week that fall before the period + // start are shown muted, so the grid reads as the selected window. + const beforeStart = ds < windowStartStr; + const outOfRange = isFuture || beforeStart; const cell = document.createElement('div'); cell.className = 'heatmap-cell'; if (ds === todayStr) cell.classList.add('heatmap-today'); - if (!isFuture && count > 0) { + if (!outOfRange && count > 0) { const opacity = count <= 2 ? 0.4 : count <= 4 ? 0.62 : count <= 7 ? 0.82 : 1.0; cell.style.background = `rgba(${pr},${pg},${pb},${opacity})`; cell.style.borderColor = `rgba(${pr},${pg},${pb},${Math.min(1, opacity * 1.3)})`; - } else if (isFuture) { + } else if (outOfRange) { cell.classList.add('heatmap-future'); } - cell.title = isFuture ? ds : `${ds}: ${count} set${count !== 1 ? 's' : ''}`; + cell.title = outOfRange ? ds : `${ds}: ${count} set${count !== 1 ? 's' : ''}`; col.appendChild(cell); } weeksEl.appendChild(col); diff --git a/internal/web/public/wall-sw.js b/internal/web/public/wall-sw.js index e861b37..ee9f2ca 100644 --- a/internal/web/public/wall-sw.js +++ b/internal/web/public/wall-sw.js @@ -12,7 +12,11 @@ // Versioning: bump CACHE_NAME when the shell changes meaningfully so // older caches get evicted on next activation. -const CACHE_NAME = "pump-wall-v3"; +// Bump on meaningful shell changes so the activate handler evicts the old +// cache. v4: the /wall/ HTML shell is now served network-first (below), so a +// stale shell can no longer be pinned on a long-lived kiosk — this bump also +// flushes any v3 cache still holding the pre-navbar shell. +const CACHE_NAME = "pump-wall-v4"; const SHELL = [ "/wall/", "/fs/public/css/wall.css", @@ -62,7 +66,24 @@ self.addEventListener("fetch", (event) => { return; } - // /fs/, /clips/, /wall/: cache-first, refresh in background. + // The /wall/ HTML shell: network-first so server-side changes (e.g. the + // shared navbar) always appear when online, falling back to cache only for + // offline boot. Cache-first here pins a stale shell on a long-lived kiosk + // that never re-navigates, so the background refresh never runs. + if (req.mode === "navigate" || url.pathname === "/wall/") { + event.respondWith( + fetch(req).then((resp) => { + if (resp && resp.ok) { + const copy = resp.clone(); + caches.open(CACHE_NAME).then((c) => c.put("/wall/", copy)).catch(() => {}); + } + return resp; + }).catch(() => caches.match(req).then((hit) => hit || caches.match("/wall/"))) + ); + return; + } + + // /fs/, /clips/: cache-first, refresh in background. event.respondWith( caches.match(req).then((hit) => { const fetchPromise = fetch(req).then((resp) => { diff --git a/internal/web/templates/health.html b/internal/web/templates/health.html index fae241e..d1dbd33 100644 --- a/internal/web/templates/health.html +++ b/internal/web/templates/health.html @@ -52,6 +52,23 @@

+ + + diff --git a/internal/web/templates/stats.html b/internal/web/templates/stats.html index 9a7cdf9..09b9fe7 100644 --- a/internal/web/templates/stats.html +++ b/internal/web/templates/stats.html @@ -267,7 +267,13 @@
-
Volume by Muscle Group
+
+ Sets by Muscle Group +
+ + +
+
@@ -276,7 +282,7 @@
-
Volume Breakdown
+
Sets Breakdown
@@ -436,6 +442,22 @@
+
+
+
+ Active Calories + Daily active energy from your tracker +
+
+
+
Today
+
Daily avg
+
+ + +
+
+
Cardio Sessions
@@ -446,7 +468,7 @@
Distance
- +