mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
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:
1 parent
1e9a03bee5
commit
3f55b6f782
3 files changed
+244
-14
No files matched your search
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user