fix(bp): keep non-physiological readings out of the seed and the series

The demo seed's random-walk BP generator could drift below the
physiological floor and mint a systolic-0 row (iOS #33). Clamp the walk
to the same band the input validator enforces (systolic >= 40,
diastolic >= 20), reusing the VALUE_RANGES floors, so a fresh seed never
writes a sub-floor reading. Add a read-side selection guard on the blood
pressure series endpoint that excludes rows below those floors from both
the series and the implicit latest point, so a corrupt row that slipped
in before the floor was enforced can never surface as the latest
reading. Export VALUE_RANGES so both sites share one source of truth.
This commit is contained in:
Marc-André Bombeck committed 2026-06-19 20:34:20 +02:00
1 parent a3b274171c
commit f002a9d565
3 files changed
+23 -6

No files matched your search

+13 -5
View File
@@ -13,6 +13,7 @@
*/
import pg from "pg";
import { VALUE_RANGES } from "../src/lib/validations/measurement";
const DATABASE_URL = process.env.DATABASE_URL;
if (!DATABASE_URL) {
@@ -55,13 +56,20 @@ function randomWalk(
target: number,
days: number,
volatility: number,
// Optional physiological band. A free walk can drift below the floor and
// mint an out-of-range row (a systolic-0 BP reading was observed in a
// fresh seed — iOS #33); clamping every step keeps the series inside the
// same band the input validator enforces.
clamp?: { min: number; max: number },
): number[] {
const values: number[] = [start];
for (let i = 1; i < days; i++) {
const prev = values[i - 1];
const drift = (target - prev) * 0.03; // mean reversion
const noise = (Math.random() - 0.5) * volatility;
values.push(Math.round((prev + drift + noise) * 10) / 10);
let next = Math.round((prev + drift + noise) * 10) / 10;
if (clamp) next = Math.min(clamp.max, Math.max(clamp.min, next));
values.push(next);
}
return values;
}
@@ -124,10 +132,10 @@ async function seed() {
// Weight: 86.5 → trending down to ~82.5
const weights = randomWalk(86.5, 82.0, days, 0.6);
// Systolic BP: 132 → improving to ~124
const sysBP = randomWalk(132, 124, days, 4);
// Diastolic BP: 85 → improving to ~78
const diaBP = randomWalk(85, 78, days, 3);
// Systolic BP: 132 → improving to ~124 (clamped to the physiological band)
const sysBP = randomWalk(132, 124, days, 4, VALUE_RANGES.BLOOD_PRESSURE_SYS);
// Diastolic BP: 85 → improving to ~78 (clamped to the physiological band)
const diaBP = randomWalk(85, 78, days, 3, VALUE_RANGES.BLOOD_PRESSURE_DIA);
// Pulse: 72 → slight improvement to ~68
const pulse = randomWalk(72, 68, days, 3);
// Body fat: 24.5% → trending to ~22%
+9
View File
@@ -23,6 +23,7 @@ import { summarize, type DataPoint } from "@/lib/analytics/trends";
import { redactSensitiveFields } from "@/lib/observability/redact-payload";
import type { MeasurementType, SleepStage } from "@/generated/prisma/client";
import { reconstructSleepNights } from "@/lib/analytics/sleep-night";
import { VALUE_RANGES } from "@/lib/validations/measurement";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose";
@@ -264,6 +265,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
};
});
} else if (kind === "bloodPressure") {
// Exclude non-physiological rows (systolic < 40 / diastolic < 20) from the
// series AND the implicit "latest" (the last point). BP is two rows paired
// client-side, so a corrupt row that slipped in before the input floor was
// 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.
const [sys, dia] = await Promise.all([
prisma.measurement.findMany({
where: {
@@ -271,6 +278,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
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 },
@@ -281,6 +289,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
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 },
+1 -1
View File
@@ -343,7 +343,7 @@ export function getUnitForType(type: string): string {
}
// Plausible ranges per measurement type
const VALUE_RANGES: Record<string, { min: number; max: number }> = {
export const VALUE_RANGES: Record<string, { min: number; max: number }> = {
WEIGHT: { min: 1, max: 500 },
BLOOD_PRESSURE_SYS: { min: 40, max: 300 },
BLOOD_PRESSURE_DIA: { min: 20, max: 200 },