Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions internal/api/health.go
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
15 changes: 8 additions & 7 deletions internal/models/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
34 changes: 26 additions & 8 deletions internal/web/healthstats.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}
Expand All @@ -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":
Expand All @@ -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 })
Expand Down Expand Up @@ -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
Expand All @@ -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
}
}
Expand Down
107 changes: 107 additions & 0 deletions internal/web/healthstats_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
44 changes: 43 additions & 1 deletion internal/web/public/js/health-charts.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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)));
Expand Down Expand Up @@ -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() {
Expand All @@ -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) {
Expand Down
Loading
Loading