From 642e78a6f7d7ab4c313e19f5dde0efb6254146c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 16 May 2026 10:36:39 +0200 Subject: [PATCH] fix(api): aggregate cumulative measurement types as a sum MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `aggregate=daily|weekly|monthly` averaged step counts (and every other cumulative HK type) instead of summing — partial-day rows collapsed to a per-bucket mean rather than the daily total. Five rows of 1000 steps reported 1000, not 5000. Add `CUMULATIVE_HK_TYPES` in `apple-health-mapping.ts` listing the five cumulative MeasurementTypes the iOS / Withings ingest path emits (`ACTIVITY_STEPS`, `ACTIVE_ENERGY_BURNED`, `FLIGHTS_CLIMBED`, `WALKING_RUNNING_DISTANCE`, `TIME_IN_DAYLIGHT`). The route picks `SUM(value)` for those types and keeps `AVG(value)` for every spot metric (BP, pulse, weight, BG, body fat, mood, sleep). Two new integration cases pin the contract: a step day collapses to the per-row sum; a pulse day still averages. --- src/app/api/measurements/route.ts | 13 +++- src/lib/measurements/apple-health-mapping.ts | 23 +++++++ .../measurements-aggregate-daily.test.ts | 67 +++++++++++++++++++ 3 files changed, 102 insertions(+), 1 deletion(-) diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index b68eac336..ee203e7e1 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -18,6 +18,7 @@ import { BUCKET_CAP, type AggregateGrain, } from "@/lib/measurements/range-aggregation"; +import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping"; import { withIdempotency } from "@/lib/idempotency"; import { NextRequest } from "next/server"; import type { @@ -89,13 +90,23 @@ export const GET = apiHandler(async (request: NextRequest) => { }; const truncUnit = TRUNC_UNIT[grain]; const truncUnitLiteral = Prisma.raw(`'${truncUnit}'`); + // Cumulative HK types (steps, active energy, flights, distance, + // daylight) are partial-day increments — averaging across the day + // understates the daily total by the per-bucket sample count. + // SUM for those; AVG for spot metrics (BP, weight, pulse, BG, + // body fat, mood, sleep). See R-A finding 2. + const useSum = + type != null && CUMULATIVE_HK_TYPES.has(type as MeasurementType); + const aggregator = useSum + ? Prisma.raw(`SUM(m."value")::double precision`) + : Prisma.raw(`AVG(m."value")::double precision`); const buckets = await prisma.$queryRaw< Array<{ type: string; bucket_start: Date; avg: number; cnt: number }> >` SELECT m."type"::text AS type, date_trunc(${truncUnitLiteral}, m."measured_at") AS bucket_start, - AVG(m."value")::double precision AS avg, + ${aggregator} AS avg, COUNT(*)::int AS cnt FROM measurements m WHERE m."user_id" = ${user.id} diff --git a/src/lib/measurements/apple-health-mapping.ts b/src/lib/measurements/apple-health-mapping.ts index a780a447b..cbad48138 100644 --- a/src/lib/measurements/apple-health-mapping.ts +++ b/src/lib/measurements/apple-health-mapping.ts @@ -287,6 +287,29 @@ export const APPLE_HEALTH_TYPE_MAP: Record = { }, }; +/** + * v1.4.29 — MeasurementTypes whose `aggregate=daily|weekly|monthly` + * grain on `GET /api/measurements` must reduce with `SUM`, not `AVG`. + * + * These are the cumulative-quantity HealthKit types where every row + * is a partial-day increment (steps for one minute, kilocalories for + * one workout, metres for one walk). Averaging across the day + * silently understates the daily total by the per-bucket sample + * count. Spot metrics (BP, weight, pulse, mood, BG, body fat, sleep) + * stay on `AVG`. + * + * Used by `src/app/api/measurements/route.ts` when picking the SQL + * aggregator. Mirrors the canonical list documented in + * `.planning/research/v15-r-a-step-aggregation.md` §6. + */ +export const CUMULATIVE_HK_TYPES: ReadonlySet = new Set([ + "ACTIVITY_STEPS", + "ACTIVE_ENERGY_BURNED", + "FLIGHTS_CLIMBED", + "WALKING_RUNNING_DISTANCE", + "TIME_IN_DAYLIGHT", +]); + /** * HK identifiers the iOS app may emit that HealthLog deliberately does * NOT map yet. Listing them here means the batch route can log a diff --git a/tests/integration/measurements-aggregate-daily.test.ts b/tests/integration/measurements-aggregate-daily.test.ts index 3069f9c00..a4b88631e 100644 --- a/tests/integration/measurements-aggregate-daily.test.ts +++ b/tests/integration/measurements-aggregate-daily.test.ts @@ -193,4 +193,71 @@ describe("GET /api/measurements?aggregate=… (real Postgres)", () => { }; expect(json.data.meta.aggregate).toBe("monthly"); }); + + // v1.4.29 — cumulative HK types (steps, active energy, flights, + // distance, daylight) must aggregate with SUM, not AVG. Five rows + // of 1000 steps each on the same day must collapse to 5000, not + // 1000. See R-A finding 2. + it("sums cumulative HK types instead of averaging", async () => { + const prisma = getPrismaClient(); + const baseTime = new Date("2026-05-01T08:00:00.000Z").getTime(); + await prisma.measurement.createMany({ + data: Array.from({ length: 5 }, (_, i) => ({ + userId: TEST_USER_ID, + type: "ACTIVITY_STEPS" as const, + value: 1000, + unit: "steps", + source: "MANUAL" as const, + measuredAt: new Date(baseTime + i * 60 * 60 * 1000), + })), + }); + + const { GET } = await import("@/app/api/measurements/route"); + const response = await GET( + getRequest( + "type=ACTIVITY_STEPS&from=2026-05-01T00:00:00Z&to=2026-05-02T00:00:00Z&aggregate=daily&limit=365", + ), + ); + + expect(response.status).toBe(200); + const json = (await response.json()) as { + data: { + measurements: Array<{ value: number; count: number }>; + }; + }; + expect(json.data.measurements).toHaveLength(1); + expect(json.data.measurements[0].value).toBe(5000); + expect(json.data.measurements[0].count).toBe(5); + }); + + it("averages spot types (pulse) rather than summing", async () => { + // Belt-and-braces guard so a future refactor that mis-wires the + // SUM branch back across every type catches loudly here. + const prisma = getPrismaClient(); + const baseTime = new Date("2026-05-01T08:00:00.000Z").getTime(); + await prisma.measurement.createMany({ + data: [60, 70, 80].map((v, i) => ({ + userId: TEST_USER_ID, + type: "PULSE" as const, + value: v, + unit: "bpm", + source: "MANUAL" as const, + measuredAt: new Date(baseTime + i * 60 * 60 * 1000), + })), + }); + + const { GET } = await import("@/app/api/measurements/route"); + const response = await GET( + getRequest( + "type=PULSE&from=2026-05-01T00:00:00Z&to=2026-05-02T00:00:00Z&aggregate=daily&limit=365", + ), + ); + + expect(response.status).toBe(200); + const json = (await response.json()) as { + data: { measurements: Array<{ value: number }> }; + }; + expect(json.data.measurements).toHaveLength(1); + expect(json.data.measurements[0].value).toBe(70); // (60+70+80)/3 + }); });