From 12e202958f01e34101195b226c561c241798500b Mon Sep 17 00:00:00 2001 From: BernhardBuckel <33808384+BernhardBuckel@users.noreply.github.com> Date: Sun, 4 Oct 2026 11:17:00 +0200 Subject: [PATCH] fix(series): follow the source priority ladder in the blood-pressure series The blood-pressure branch of /api/measurements/series still read systolic and diastolic with an unranked findMany, so with a second provider the pairs blended both sources and a reading present in both showed twice, while the other kinds already follow the ladder since the dense and raw reads were moved onto the shared helper. Each of the two types now reads the ladder-winning source per day through the same helper. The physiological floors (systolic 40, diastolic 20) and the five-minute pairing are unchanged. --- src/app/api/measurements/series/route.ts | 54 ++++++++++-------- .../integration/series-source-ladder.test.ts | 56 ++++++++++++++++++- 2 files changed, 87 insertions(+), 23 deletions(-) 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"] });