From 3f55b6f782f1f47f94725f8880cf1fd673892e83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 17 May 2026 12:26:02 +0200 Subject: [PATCH] fix(dashboard): bucket cumulative metrics per day before summarising MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Steps tile, Active-Energy tile, Walking + Running Distance tile, Flights-Climbed tile and Time-in-Daylight tile all rendered the *latest minute-level slice* from Apple Health as `summary.latest` instead of the day's total. The user-facing result was a Steps tile that read "1,247 steps today" when the actual day sum was closer to 8,000 — the tile was showing the freshest slice, not the running total. SLEEP_DURATION already ran the per-day-source-pick + per-day-sum collapse inline in /api/analytics. Extract that reduction into `pickCumulativeDaySum` and broaden it to ACTIVITY_STEPS, ACTIVE_ENERGY_BURNED, WALKING_RUNNING_DISTANCE, FLIGHTS_CLIMBED, and TIME_IN_DAYLIGHT. Each cumulative type maps to its own SourcePriorityMetricKey for the canonical-source pick (TIME_IN_DAYLIGHT falls through because there is no clinical-grade competitor today). After the collapse, `summary.latest` is the most recent day's total, which is what the dashboard tile expects. Tests: 7 new unit cases on the pure helper; SLEEP_DURATION integration regression still green. --- src/app/api/analytics/route.ts | 60 +++++++--- .../__tests__/cumulative-day-sum.test.ts | 107 ++++++++++++++++++ src/lib/measurements/cumulative-day-sum.ts | 91 +++++++++++++++ 3 files changed, 244 insertions(+), 14 deletions(-) create mode 100644 src/lib/measurements/__tests__/cumulative-day-sum.test.ts create mode 100644 src/lib/measurements/cumulative-day-sum.ts diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index fbb3ca4ab..d92dd8b29 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -30,6 +30,10 @@ import type { import { measurementTypeEnum } from "@/lib/validations/measurement"; import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority"; import { ensureUserRollupsFresh } from "@/lib/measurements/rollups"; +import { + isCumulativeDaySumType, + pickCumulativeDaySum, +} from "@/lib/measurements/cumulative-day-sum"; export const dynamic = "force-dynamic"; @@ -208,21 +212,49 @@ async function buildAnalyticsResponse(user: AuthedUser) { sourcePriorityJson, (d) => userDayKey(d, userTz), ).canonicalRows; - const byDay = new Map(); - for (const m of sleepRows) { - const key = userDayKey(m.measuredAt, userTz); - const slot = byDay.get(key) ?? { - total: 0, - date: m.measuredAt, - }; - slot.total += m.value; - if (m.measuredAt > slot.date) slot.date = m.measuredAt; - byDay.set(key, slot); - } - datapoints = Array.from(byDay.values()).map( - (s): DataPoint => ({ date: s.date, value: s.total }), + datapoints = pickCumulativeDaySum(sleepRows, (d) => + userDayKey(d, userTz), + ); + } else if (isCumulativeDaySumType(type)) { + // v1.4.36 W4c — broaden the SLEEP_DURATION day-bucket-sum + // pattern to every cumulative metric (steps, active energy, + // walking + running distance, flights climbed, time in + // daylight). Apple Health writes minute-level slices for + // these series; the dashboard tile was reading the + // *latest slice* via `summary.latest` instead of the day's + // total, which Marc reported as "Steps tile shows + // last-measurement-not-day-sum". After the per-day sum + // collapse, `summary.latest` is the most-recent day's + // total — exactly what the tile expects. + // + // Map cumulative MeasurementType → SourcePriorityMetricKey + // for the canonical-row picker. TIME_IN_DAYLIGHT has no + // dedicated priority ladder (no clinical-grade competitor + // to Apple Health for daylight minutes today) so it falls + // through `pickCanonicalSourceRows`'s "no ladder" + // pass-through branch — we ALWAYS bucket-and-sum it + // regardless of source. + const metricKey = + type === "ACTIVITY_STEPS" + ? ("steps" as const) + : type === "ACTIVE_ENERGY_BURNED" + ? ("activeEnergy" as const) + : type === "WALKING_RUNNING_DISTANCE" + ? ("walkingRunningDistance" as const) + : type === "FLIGHTS_CLIMBED" + ? ("flightsClimbed" as const) + : null; + const canonicalRows = metricKey + ? pickCanonicalSourceRows( + measurements, + metricKey, + sourcePriorityJson, + (d) => userDayKey(d, userTz), + ).canonicalRows + : measurements; + datapoints = pickCumulativeDaySum(canonicalRows, (d) => + userDayKey(d, userTz), ); - datapoints.sort((a, b) => a.date.getTime() - b.date.getTime()); } else { datapoints = measurements.map( (m): DataPoint => ({ diff --git a/src/lib/measurements/__tests__/cumulative-day-sum.test.ts b/src/lib/measurements/__tests__/cumulative-day-sum.test.ts new file mode 100644 index 000000000..9ad9f52db --- /dev/null +++ b/src/lib/measurements/__tests__/cumulative-day-sum.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; + +import { + CUMULATIVE_DAY_SUM_TYPES, + isCumulativeDaySumType, + pickCumulativeDaySum, +} from "../cumulative-day-sum"; + +/** + * v1.4.36 W4c — pickCumulativeDaySum unit tests. + * + * The helper bucket-and-sums a series of cumulative samples per + * `dayKey(measuredAt)`. Tested in pure ISO-date space so the + * tz-aware caller (analytics route) can layer its own `userDayKey` + * on top without dragging a TZ runtime into the helper. + */ + +// Use an ISO-date key so the test stays deterministic without a TZ. +const isoDay = (d: Date) => d.toISOString().slice(0, 10); + +describe("pickCumulativeDaySum", () => { + it("returns an empty array when the input is empty", () => { + expect(pickCumulativeDaySum([], isoDay)).toEqual([]); + }); + + it("sums a single source within one day to one point", () => { + const result = pickCumulativeDaySum( + [ + { measuredAt: new Date("2026-05-17T08:00:00Z"), value: 1200 }, + { measuredAt: new Date("2026-05-17T12:30:00Z"), value: 2500 }, + { measuredAt: new Date("2026-05-17T19:45:00Z"), value: 4300 }, + ], + isoDay, + ); + expect(result).toHaveLength(1); + expect(result[0].value).toBe(8000); + // The bucket's date is the latest measuredAt in that bucket. + expect(result[0].date.toISOString()).toBe("2026-05-17T19:45:00.000Z"); + }); + + it("preserves source-priority ordering by ignoring it (caller's job)", () => { + // The helper does not look at `source`. Two sources collapsed in + // ONE bucket would double-count — the analytics route prevents + // that by running `pickCanonicalSourceRows` first. The helper's + // contract is: trust the input order, just bucket-and-sum. + const result = pickCumulativeDaySum( + [ + { measuredAt: new Date("2026-05-17T08:00:00Z"), value: 1000 }, + { measuredAt: new Date("2026-05-17T08:00:00Z"), value: 1500 }, + ], + isoDay, + ); + expect(result).toHaveLength(1); + expect(result[0].value).toBe(2500); + }); + + it("buckets multiple days and only sums within each day", () => { + const result = pickCumulativeDaySum( + [ + // Day 1 — total 5000 + { measuredAt: new Date("2026-05-15T08:00:00Z"), value: 2000 }, + { measuredAt: new Date("2026-05-15T20:00:00Z"), value: 3000 }, + // Day 2 — total 1200 + { measuredAt: new Date("2026-05-16T09:00:00Z"), value: 700 }, + { measuredAt: new Date("2026-05-16T11:00:00Z"), value: 500 }, + // Day 3 — total 9000 (today) + { measuredAt: new Date("2026-05-17T07:00:00Z"), value: 4000 }, + { measuredAt: new Date("2026-05-17T13:00:00Z"), value: 5000 }, + ], + isoDay, + ); + expect(result).toHaveLength(3); + expect(result.map((p) => p.value)).toEqual([5000, 1200, 9000]); + // Output is sorted ascending by date. + expect(result[0].date.toISOString()).toBe("2026-05-15T20:00:00.000Z"); + expect(result[2].date.toISOString()).toBe("2026-05-17T13:00:00.000Z"); + }); + + it("each bucket's date is the latest measuredAt in that bucket", () => { + const result = pickCumulativeDaySum( + [ + { measuredAt: new Date("2026-05-17T07:00:00Z"), value: 100 }, + { measuredAt: new Date("2026-05-17T15:00:00Z"), value: 200 }, + { measuredAt: new Date("2026-05-17T10:00:00Z"), value: 50 }, + ], + isoDay, + ); + expect(result).toHaveLength(1); + expect(result[0].date.toISOString()).toBe("2026-05-17T15:00:00.000Z"); + expect(result[0].value).toBe(350); + }); +}); + +describe("isCumulativeDaySumType", () => { + it("returns true for every metric in the canonical list", () => { + for (const t of CUMULATIVE_DAY_SUM_TYPES) { + expect(isCumulativeDaySumType(t)).toBe(true); + } + }); + + it("returns false for non-cumulative types", () => { + expect(isCumulativeDaySumType("BLOOD_PRESSURE_SYSTOLIC")).toBe(false); + expect(isCumulativeDaySumType("WEIGHT")).toBe(false); + expect(isCumulativeDaySumType("SLEEP_DURATION")).toBe(false); + expect(isCumulativeDaySumType("MOOD")).toBe(false); + }); +}); diff --git a/src/lib/measurements/cumulative-day-sum.ts b/src/lib/measurements/cumulative-day-sum.ts new file mode 100644 index 000000000..3648d3a9f --- /dev/null +++ b/src/lib/measurements/cumulative-day-sum.ts @@ -0,0 +1,91 @@ +/** + * v1.4.36 W4c — Pure day-bucket sum picker for cumulative metrics. + * + * SLEEP_DURATION already runs this shape inline in + * `src/app/api/analytics/route.ts` lines 198-225: pick a single + * source per day (source-priority + device-type) then sum the + * day's slices into one datapoint per day. The dashboard tile + * surfaces the resulting `latest` as "X minutes asleep". + * + * Steps / active energy / walking distance / flights climbed / + * time-in-daylight share the same physical shape — every iOS + * HealthKit ingest writes minute-level slices that the dashboard + * tile was rendering as the *latest slice* instead of the *day's + * total*. Marc reported "Steps tile shows last-measurement-not-day-sum". + * + * This helper extracts the per-day-sum reduction so the route can + * call it for every cumulative metric without copy-pasting the + * SLEEP_DURATION branch. The source-priority pick stays in the + * caller (it already imports `pickCanonicalSourceRows`); this + * helper only handles the bucket-and-sum once that picker has + * narrowed each day to its canonical source. + */ + +export interface CumulativeRow { + measuredAt: Date; + value: number; +} + +export interface DaySumPoint { + date: Date; + value: number; +} + +/** + * Bucket `rows` by `dayKey(measuredAt)` and sum each bucket's `value`. + * The returned points are sorted ascending by date, with each + * bucket's `date` set to the latest `measuredAt` seen in that + * bucket (matches the SLEEP_DURATION branch's contract). + * + * Empty input returns an empty array. + */ +export function pickCumulativeDaySum( + rows: readonly T[], + dayKey: (d: Date) => string, +): DaySumPoint[] { + if (rows.length === 0) return []; + + const byDay = new Map(); + for (const row of rows) { + const key = dayKey(row.measuredAt); + const slot = byDay.get(key) ?? { total: 0, date: row.measuredAt }; + slot.total += row.value; + if (row.measuredAt > slot.date) slot.date = row.measuredAt; + byDay.set(key, slot); + } + + const out: DaySumPoint[] = Array.from(byDay.values()).map((s) => ({ + date: s.date, + value: s.total, + })); + out.sort((a, b) => a.date.getTime() - b.date.getTime()); + return out; +} + +/** + * v1.4.36 W4c — Canonical set of cumulative metric types whose + * dashboard tile should read the *day's sum* and not the latest + * slice. Kept here so the analytics route and any other consumer + * (Coach snapshot, exports) share the same source of truth. + * + * Note: SLEEP_DURATION is also cumulative but the route handles + * it inline because the picker is the canonical sleep source ladder + * ("sleep" metricKey vs "default"). Once Apple-Health passthrough + * lands and the sleep ladder needs the same shared treatment it can + * fold into this list. + */ +export const CUMULATIVE_DAY_SUM_TYPES = [ + "ACTIVITY_STEPS", + "ACTIVE_ENERGY_BURNED", + "WALKING_RUNNING_DISTANCE", + "FLIGHTS_CLIMBED", + "TIME_IN_DAYLIGHT", +] as const; + +export type CumulativeDaySumType = (typeof CUMULATIVE_DAY_SUM_TYPES)[number]; + +export function isCumulativeDaySumType( + type: string, +): type is CumulativeDaySumType { + return (CUMULATIVE_DAY_SUM_TYPES as readonly string[]).includes(type); +}