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.
This commit is contained in:
BernhardBuckel committed 2026-10-04 11:17:00 +02:00
1 parent 808b2672cd
commit 12e202958f
2 files changed
+87 -23

No files matched your search

+32 -22
View File
@@ -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
+55 -1
View File
@@ -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"] });