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 `
- Wearable metrics (steps, heart rate, sleep, cardio) sync from your fitness tracker via Android Health Connect. Tiles show – until data arrives. Tap any tile for the full chart.
+ Wearable metrics (steps, active calories, heart rate, sleep, cardio) sync from your fitness tracker via Android Health Connect. Tiles show – until data arrives. Tap any tile for the full chart.