fix(dashboard): bucket cumulative metrics per day before summarising

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.
This commit is contained in:
Marc-André Bombeck committed 2026-05-17 12:26:02 +02:00
1 parent 1e9a03bee5
commit 3f55b6f782
3 files changed
+244 -14

No files matched your search

+46 -14
View File
@@ -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<string, { total: number; date: Date }>();
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 => ({
@@ -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);
});
});
@@ -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<T extends CumulativeRow>(
rows: readonly T[],
dayKey: (d: Date) => string,
): DaySumPoint[] {
if (rows.length === 0) return [];
const byDay = new Map<string, { total: number; date: Date }>();
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);
}