diff --git a/README.md b/README.md index 3e33f4c..5032480 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,9 @@ |---|---|---| | ![Stats Steps](assets/screenshot-stats-steps.png) | ![Stats Heart Rate](assets/screenshot-stats-hr.png) | ![Stats Sleep](assets/screenshot-stats-sleep.png) | -| Stats: Cardio | | | +| Stats: Cardio | Stats: Muscle Balance | | |---|---|---| -| ![Stats Cardio](assets/screenshot-stats-cardio.png) | | | +| ![Stats Cardio](assets/screenshot-stats-cardio.png) | ![Stats Muscle Balance](assets/screenshot-stats-balance.png) | | - [Architecture](#architecture) - [Configuration](#configuration) diff --git a/assets/screenshot-config.png b/assets/screenshot-config.png index be227ee..3152e63 100644 Binary files a/assets/screenshot-config.png and b/assets/screenshot-config.png differ diff --git a/assets/screenshot-health.png b/assets/screenshot-health.png index a2e1917..8096d17 100644 Binary files a/assets/screenshot-health.png and b/assets/screenshot-health.png differ diff --git a/assets/screenshot-stats-activity.png b/assets/screenshot-stats-activity.png index 8d36a1d..430e51a 100644 Binary files a/assets/screenshot-stats-activity.png and b/assets/screenshot-stats-activity.png differ diff --git a/assets/screenshot-stats-balance.png b/assets/screenshot-stats-balance.png new file mode 100644 index 0000000..7c12f8c Binary files /dev/null and b/assets/screenshot-stats-balance.png differ diff --git a/assets/screenshot-stats-cardio.png b/assets/screenshot-stats-cardio.png index b69aab5..21f4965 100644 Binary files a/assets/screenshot-stats-cardio.png and b/assets/screenshot-stats-cardio.png differ diff --git a/assets/screenshot-stats-hr.png b/assets/screenshot-stats-hr.png index ae936b5..fe84bec 100644 Binary files a/assets/screenshot-stats-hr.png and b/assets/screenshot-stats-hr.png differ diff --git a/assets/screenshot-stats-overview.png b/assets/screenshot-stats-overview.png index d847c2e..0dda2d7 100644 Binary files a/assets/screenshot-stats-overview.png and b/assets/screenshot-stats-overview.png differ diff --git a/assets/screenshot-stats-sleep.png b/assets/screenshot-stats-sleep.png index 6d50a83..e315696 100644 Binary files a/assets/screenshot-stats-sleep.png and b/assets/screenshot-stats-sleep.png differ diff --git a/assets/screenshot-stats-steps.png b/assets/screenshot-stats-steps.png index 385aa2d..230dde4 100644 Binary files a/assets/screenshot-stats-steps.png and b/assets/screenshot-stats-steps.png differ diff --git a/assets/screenshot-stats-weight.png b/assets/screenshot-stats-weight.png index fa6211c..0b10592 100644 Binary files a/assets/screenshot-stats-weight.png and b/assets/screenshot-stats-weight.png differ diff --git a/assets/screenshot-workout.png b/assets/screenshot-workout.png index 22e8717..4947883 100644 Binary files a/assets/screenshot-workout.png and b/assets/screenshot-workout.png differ diff --git a/internal/web/healthstats.go b/internal/web/healthstats.go index 2225906..3179ae5 100644 --- a/internal/web/healthstats.go +++ b/internal/web/healthstats.go @@ -40,8 +40,16 @@ func loadHealthStats() models.HealthStats { func aggregateHealth(recs []models.HealthRecord) models.HealthStats { out := models.HealthStats{Available: true} - stepsByDay := map[string]float64{} - caloriesByDay := map[string]float64{} + // steps and active_calories arrive as cumulative daily-total snapshots: + // the bridge re-reports the running daily figure with the same midnight + // start_time and a growing end_time (and dedup keys on end_time, so every + // snapshot persists). Summing them multiplies the real total by the number + // of snapshots. Accumulate per (day, start_time) taking the MAX, then sum + // across distinct start_times per day — correct for both re-reported daily + // snapshots (one start ⇒ the final total) and genuine sub-day intervals + // (distinct starts ⇒ summed). + stepsByDay := map[string]map[int64]float64{} + caloriesByDay := map[string]map[int64]float64{} restingByDay := map[string][]float64{} hrByDay := map[string][]float64{} sleepByDay := map[string]*models.SleepNight{} @@ -58,9 +66,9 @@ func aggregateHealth(recs []models.HealthRecord) models.HealthStats { day := r.StartTime.Local().Format("2006-01-02") switch r.MetricType { case "steps": - stepsByDay[day] += val(r) + addSnapshot(stepsByDay, day, r.StartTime.Unix(), val(r)) case "active_calories": - caloriesByDay[day] += val(r) + addSnapshot(caloriesByDay, day, r.StartTime.Unix(), val(r)) case "resting_heart_rate": restingByDay[day] = append(restingByDay[day], val(r)) case "heart_rate": @@ -83,11 +91,11 @@ 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, m := range stepsByDay { + out.DailySteps = append(out.DailySteps, models.DayValue{Date: d, Value: sumSnapshots(m)}) } - for d, v := range caloriesByDay { - out.ActiveCalories = append(out.ActiveCalories, models.DayValue{Date: d, Value: v}) + for d, m := range caloriesByDay { + out.ActiveCalories = append(out.ActiveCalories, models.DayValue{Date: d, Value: sumSnapshots(m)}) } for d, vs := range hrByDay { mn, av, mx := minAvgMax(vs) @@ -117,6 +125,27 @@ func aggregateHealth(recs []models.HealthRecord) models.HealthStats { return out } +// addSnapshot records v as the max seen for (day, start) — collapsing the +// bridge's re-reported cumulative snapshots of the same interval to their +// final value. See the comment in aggregateHealth. +func addSnapshot(m map[string]map[int64]float64, day string, start int64, v float64) { + if m[day] == nil { + m[day] = map[int64]float64{} + } + if v > m[day][start] { + m[day][start] = v + } +} + +// sumSnapshots totals the per-start maxima for a day. +func sumSnapshots(m map[int64]float64) float64 { + var s float64 + for _, v := range m { + s += v + } + return s +} + func sortByDate(s []models.DayValue) { sort.Slice(s, func(i, j int) bool { return s[i].Date < s[j].Date }) } diff --git a/internal/web/healthstats_test.go b/internal/web/healthstats_test.go index 833b19f..92fb021 100644 --- a/internal/web/healthstats_test.go +++ b/internal/web/healthstats_test.go @@ -91,17 +91,41 @@ func TestAggregateHealthRestingHRRealWins(t *testing.T) { } } -// TestAggregateHealthActiveCalories verifies active_calories sums per day. -func TestAggregateHealthActiveCalories(t *testing.T) { +// TestAggregateHealthDistinctIntervalsSum verifies that genuine sub-day +// intervals (distinct start_times) sum, for both steps and active calories. +func TestAggregateHealthDistinctIntervalsSum(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}`), + rec("active_calories", d, 174, ""), + rec("active_calories", d.Add(time.Hour), 89, ""), + rec("steps", d, 1000, ""), + rec("steps", d.Add(time.Hour), 500, ""), }) - if len(out.ActiveCalories) != 1 { - t.Fatalf("want 1 active-calorie day, got %d", len(out.ActiveCalories)) + if len(out.ActiveCalories) != 1 || out.ActiveCalories[0].Value != 263 { + t.Errorf("active calories distinct intervals: want 263 (174+89), got %+v", out.ActiveCalories) } - if out.ActiveCalories[0].Value != 263 { - t.Errorf("active calories: want 263 (174+89), got %v", out.ActiveCalories[0].Value) + if len(out.DailySteps) != 1 || out.DailySteps[0].Value != 1500 { + t.Errorf("steps distinct intervals: want 1500 (1000+500), got %+v", out.DailySteps) + } +} + +// TestAggregateHealthCumulativeSnapshots verifies that re-reported cumulative +// daily snapshots (same midnight start, growing running total) collapse to the +// day's MAX instead of summing — the bug that inflated steps ~9x. +func TestAggregateHealthCumulativeSnapshots(t *testing.T) { + d := time.Date(2026, 6, 8, 0, 0, 0, 0, time.Local) + out := aggregateHealth([]models.HealthRecord{ + rec("steps", d, 2660, ""), + rec("steps", d, 3146, ""), + rec("steps", d, 4607, ""), + rec("active_calories", d, 100, ""), + rec("active_calories", d, 263, ""), + rec("active_calories", d, 263, ""), + }) + if len(out.DailySteps) != 1 || out.DailySteps[0].Value != 4607 { + t.Errorf("steps: want daily max 4607 (not summed), got %+v", out.DailySteps) + } + if len(out.ActiveCalories) != 1 || out.ActiveCalories[0].Value != 263 { + t.Errorf("active calories: want daily max 263 (not summed), got %+v", out.ActiveCalories) } }