fix(api): aggregate cumulative measurement types as a sum

`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.
This commit is contained in:
Marc-André Bombeck committed 2026-05-16 10:36:39 +02:00
1 parent 6e75463fef
commit 642e78a6f7
3 files changed
+102 -1

No files matched your search

+12 -1
View File
@@ -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}
@@ -287,6 +287,29 @@ export const APPLE_HEALTH_TYPE_MAP: Record<string, AppleHealthMapping> = {
},
};
/**
* 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<MeasurementType> = new Set<MeasurementType>([
"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
@@ -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
});
});