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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Binary file modified assets/screenshot-config.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/screenshot-health.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/screenshot-stats-activity.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/screenshot-stats-balance.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/screenshot-stats-cardio.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/screenshot-stats-hr.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/screenshot-stats-overview.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/screenshot-stats-sleep.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/screenshot-stats-steps.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/screenshot-stats-weight.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/screenshot-workout.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
45 changes: 37 additions & 8 deletions internal/web/healthstats.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}
Expand All @@ -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":
Expand All @@ -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)
Expand Down Expand Up @@ -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 })
}
Expand Down
40 changes: 32 additions & 8 deletions internal/web/healthstats_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading