From 4d0c4d95660a329f17f1fc55b50ea98f37d315e5 Mon Sep 17 00:00:00 2001 From: Vincent Royer Date: Fri, 26 Jun 2026 10:58:52 -0700 Subject: [PATCH] bodyweights: collapse to one row per local day (#30) Bodyweight is read off each workout post, so logging two workouts on the same calendar day produced two `bodyweights list` rows for that date (both markdown and JSON). Downstream per-day trend and plateau math then double-counts that day. Deduplicate in loadBodyweightEntries (shared by `list` and `stats`), keeping the latest measurement per local day. This also makes the stats monthly averages one-sample-per-day, which is the intended model: a day has a single bodyweight. Adds tests covering the same-day collision (keep latest) and the no-duplicate passthrough. Co-Authored-By: Claude Opus 4.8 --- cmd/bodyweights.go | 22 ++++++++++++++++++ cmd/bodyweights_test.go | 51 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 cmd/bodyweights_test.go diff --git a/cmd/bodyweights.go b/cmd/bodyweights.go index 78b536f..b55fc08 100644 --- a/cmd/bodyweights.go +++ b/cmd/bodyweights.go @@ -178,6 +178,8 @@ func loadBodyweightEntries(sinceFlag, untilFlag string) ([]bodyweightEntry, erro }) } + entries = dedupeByDay(entries) + sort.Slice(entries, func(i, j int) bool { return entries[i].date.Before(entries[j].date) }) @@ -185,6 +187,26 @@ func loadBodyweightEntries(sinceFlag, untilFlag string) ([]bodyweightEntry, erro return entries, nil } +// dedupeByDay collapses entries to one per local calendar day. Bodyweight is +// read off each workout post, so logging two workouts on the same day yields +// two entries for that date (#30). When a day has more than one measurement, +// keep the latest one (by StartedAt timestamp). Order is not guaranteed; the +// caller sorts afterward. +func dedupeByDay(entries []bodyweightEntry) []bodyweightEntry { + latest := make(map[string]bodyweightEntry, len(entries)) + for _, e := range entries { + key := e.date.Format("2006-01-02") + if cur, ok := latest[key]; !ok || e.date.After(cur.date) { + latest[key] = e + } + } + out := make([]bodyweightEntry, 0, len(latest)) + for _, e := range latest { + out = append(out, e) + } + return out +} + func printBodyweightStats(entries []bodyweightEntry) { first := entries[0] last := entries[len(entries)-1] diff --git a/cmd/bodyweights_test.go b/cmd/bodyweights_test.go new file mode 100644 index 0000000..853624d --- /dev/null +++ b/cmd/bodyweights_test.go @@ -0,0 +1,51 @@ +package cmd + +import ( + "sort" + "testing" + "time" +) + +// Two workouts on the same calendar day must collapse to a single +// bodyweight row, keeping the latest measurement that day. (#30) +func TestDedupeByDay_KeepsLatestPerDay(t *testing.T) { + mk := func(ts string, w float64) bodyweightEntry { + d, err := time.Parse(time.RFC3339, ts) + if err != nil { + t.Fatalf("bad test timestamp %q: %v", ts, err) + } + return bodyweightEntry{date: d, weight: w} + } + + in := []bodyweightEntry{ + mk("2026-05-02T07:00:00Z", 175.0), // morning workout + mk("2026-05-02T18:00:00Z", 177.0), // evening workout, same day + mk("2026-05-03T08:00:00Z", 176.0), + } + + out := dedupeByDay(in) + sort.Slice(out, func(i, j int) bool { return out[i].date.Before(out[j].date) }) + + if len(out) != 2 { + t.Fatalf("expected 2 rows after dedupe (one per day), got %d: %+v", len(out), out) + } + if got := out[0].date.Format("2006-01-02"); got != "2026-05-02" { + t.Errorf("first row date = %s, want 2026-05-02", got) + } + if out[0].weight != 177.0 { + t.Errorf("same-day collision should keep the latest measurement (177.0), got %v", out[0].weight) + } + if out[1].weight != 176.0 { + t.Errorf("second day weight = %v, want 176.0", out[1].weight) + } +} + +// A single entry per day passes through unchanged. +func TestDedupeByDay_NoDuplicates(t *testing.T) { + d1, _ := time.Parse(time.RFC3339, "2026-01-01T10:00:00Z") + d2, _ := time.Parse(time.RFC3339, "2026-01-02T10:00:00Z") + in := []bodyweightEntry{{date: d1, weight: 180}, {date: d2, weight: 181}} + if got := len(dedupeByDay(in)); got != 2 { + t.Errorf("expected 2 rows, got %d", got) + } +}