mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
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:
1 parent
6e75463fef
commit
642e78a6f7
3 files changed
+102
-1
No files matched your search
@@ -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
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user