mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
1 parent
a3b274171c
commit
f002a9d565
3 files changed
+23
-6
No files matched your search
+13
-5
@@ -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%
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in new issue
Block a user