diff --git a/src/app/api/measurements/series/route.ts b/src/app/api/measurements/series/route.ts index 35b22894e..461048430 100644 --- a/src/app/api/measurements/series/route.ts +++ b/src/app/api/measurements/series/route.ts @@ -329,29 +329,39 @@ export const GET = apiHandler(async (request: NextRequest) => { // enforced — a seed-era systolic-0, iOS #33 — must never surface as the // latest reading. Floors mirror VALUE_RANGES (the input validator's min); // this is a read-side selection guard, not a tightening of the write floor. + // Each of the two types reads the ladder-winning source per day (see + // `seriesRowsFrom`), like the other kinds, so a second provider is not + // blended into the pairs and a reading present in both appears once. + const bpPriority = await loadUserSourcePriority(user.id); + const readBp = ( + type: "BLOOD_PRESSURE_SYS" | "BLOOD_PRESSURE_DIA", + floor: number, + ) => + prisma + .$queryRawUnsafe< + Array<{ id: string; value: number; measured_at: Date }> + >( + ` + SELECT m."id", m."value", m."measured_at" + FROM ${seriesRowsFrom(bpPriority, type, days)} + WHERE m."measured_at" >= $2 + AND m."value" >= $3 + ORDER BY m."measured_at" ASC, m."id" ASC + `, + user.id, + since, + floor, + ) + .then((rows) => + rows.map((r) => ({ + id: r.id, + value: r.value, + measuredAt: r.measured_at, + })), + ); const [sys, dia] = await Promise.all([ - prisma.measurement.findMany({ - where: { - userId: user.id, - type: "BLOOD_PRESSURE_SYS", - measuredAt: { gte: since }, - deletedAt: null, - value: { gte: VALUE_RANGES.BLOOD_PRESSURE_SYS.min }, - }, - orderBy: { measuredAt: "asc" }, - select: { id: true, value: true, measuredAt: true }, - }), - prisma.measurement.findMany({ - where: { - userId: user.id, - type: "BLOOD_PRESSURE_DIA", - measuredAt: { gte: since }, - deletedAt: null, - value: { gte: VALUE_RANGES.BLOOD_PRESSURE_DIA.min }, - }, - orderBy: { measuredAt: "asc" }, - select: { id: true, value: true, measuredAt: true }, - }), + readBp("BLOOD_PRESSURE_SYS", VALUE_RANGES.BLOOD_PRESSURE_SYS.min), + readBp("BLOOD_PRESSURE_DIA", VALUE_RANGES.BLOOD_PRESSURE_DIA.min), ]); // Pair by closest timestamp within ±5 minutes. Both reads arrive diff --git a/tests/integration/series-source-ladder.test.ts b/tests/integration/series-source-ladder.test.ts index efd914c06..6cb75c099 100644 --- a/tests/integration/series-source-ladder.test.ts +++ b/tests/integration/series-source-ladder.test.ts @@ -114,7 +114,12 @@ async function series(userId: string, kind: string, days: number) { return ( (await res.json()) as { data: { - points: Array<{ id: string; at: string; value: number }>; + points: Array<{ + id: string; + at: string; + value: number; + secondary: number | null; + }>; stats: { count: number; mean: number; min: number; max: number }; }; } @@ -146,6 +151,25 @@ beforeAll(async () => { dual.push(row(DUAL, "WEIGHT", "kg", "MANUAL", 80 + d, at(d))); dual.push(row(DUAL, "WEIGHT", "kg", "WITHINGS", 80 + d, at(d))); } + // Blood pressure: Apple Health (120/80, 122/82) on days 1-2, Withings + // (140/90, 142/92) on days 2-3. Day 2 has both sources; day 1 only Apple, + // day 3 only Withings. A sub-floor systolic (0) on day 1 must stay out. + const bp = ( + source: MeasurementSource, + d: number, + sys: number, + dia: number, + ) => { + dual.push(row(DUAL, "BLOOD_PRESSURE_SYS", "mmHg", source, sys, at(d, 10))); + dual.push(row(DUAL, "BLOOD_PRESSURE_DIA", "mmHg", source, dia, at(d, 10))); + }; + bp("APPLE_HEALTH", 1, 120, 80); + bp("APPLE_HEALTH", 2, 122, 82); + bp("WITHINGS", 2, 142, 92); + bp("WITHINGS", 3, 140, 90); + dual.push( + row(DUAL, "BLOOD_PRESSURE_SYS", "mmHg", "APPLE_HEALTH", 0, at(1, 20)), + ); // Glucose has no ladder: both sources must stay. for (const d of [1, 2]) { dual.push(row(DUAL, "BLOOD_GLUCOSE", "mg/dL", "APPLE_HEALTH", 100, at(d))); @@ -220,6 +244,36 @@ describe("pulse follows the ladder (raw read, 30 days)", () => { }); }); +describe("blood pressure follows the ladder", () => { + const bpSeries = async () => (await series(DUAL, "bloodPressure", 30)).points; + + it("shows the ladder's source on a day both cover, and the other where only it exists", async () => { + await setLadder(DUAL, { bloodPressure: ["APPLE_HEALTH", "WITHINGS"] }); + const points = await bpSeries(); + // Day 1 Apple, day 2 Apple (Withings' 142/92 is out), day 3 only Withings. + expect(points.map((p) => [p.value, p.secondary])).toEqual([ + [140, 90], + [122, 82], + [120, 80], + ]); + }); + + it("flips with the ladder", async () => { + await setLadder(DUAL, { bloodPressure: ["WITHINGS", "APPLE_HEALTH"] }); + const points = await bpSeries(); + expect(points.map((p) => [p.value, p.secondary])).toEqual([ + [140, 90], + [142, 92], + [120, 80], + ]); + }); + + it("keeps a non-physiological systolic out of the series", async () => { + await setLadder(DUAL, { bloodPressure: ["APPLE_HEALTH", "WITHINGS"] }); + expect((await bpSeries()).every((p) => p.value >= 40)).toBe(true); + }); +}); + describe("a reading mirrored into two sources", () => { it("appears once", async () => { await setLadder(DUAL, { weight: ["WITHINGS", "MANUAL"] });