From 75303e8b849cc98517b8c3f3f5fd707659f31f8f Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Tue, 21 Jul 2026 20:21:47 +0800 Subject: [PATCH 1/3] fix(medication-safety): fail-safe physiological bounds for patient-profile inputs (FV-03) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Patient-considerations profile numeric fields (age/eGFR/CrCl/QTc/serum creatinine) fed the medication-safety alert engine with no validation, so a garbage-but-present value in a gate's non-firing direction (e.g. a negative QTc) could silently flip a contraindication from the fail-safe "unassessed" state to a false all-clear. - sanitizeProfile now rejects physiologically impossible / out-of-range values to null (never clamps), routing them into the existing fail-closed unassessed path. Bounds are input-validity only and kept separate from the clinical firing thresholds. Serum creatinine is unit-aware (µmol/L canonical, mg/dL normalised x88.4). - Close a latent false-all-clear hole in the bare-renal factor: the "eGFR or CrCl" unassessed signal now fires when EITHER renal input is missing and neither fired (was: both missing). No behaviour change on the current corpus (0 bare-renal contraindication rows) — pure hardening. - Patient profile form gains min/max + aria-invalid + an inline out-of-range message; the invalid entry is never committed to the shared store. Fix design adversarially verified before implementation: null-routing can never convert a firing contraindication into a false all-clear (once the bare-renal hole is closed), and the bounds never reject a legitimate clinical value. Verified offline: typecheck, lint, format:check, full unit+jsdom suite (349 files / 3120 passed / 0 failed), design-system-contract (baselines unchanged), type-scale, icon-scale, check:production-readiness (READY). Co-Authored-By: Claude Opus 4.8 --- .../patient-profile-panel.tsx | 85 ++++++++++++- src/lib/medication-patient-alerts.ts | 7 +- src/lib/patient-profile-storage.ts | 52 ++++++-- tests/medication-patient-alerts.test.ts | 36 ++++++ tests/patient-profile-panel.dom.test.tsx | 89 +++++++++++++ tests/patient-profile-storage.test.ts | 120 ++++++++++++++++++ 6 files changed, 375 insertions(+), 14 deletions(-) create mode 100644 tests/patient-profile-panel.dom.test.tsx create mode 100644 tests/patient-profile-storage.test.ts diff --git a/src/components/clinical-dashboard/patient-profile-panel.tsx b/src/components/clinical-dashboard/patient-profile-panel.tsx index dcad3a6cd8..5029974bb0 100644 --- a/src/components/clinical-dashboard/patient-profile-panel.tsx +++ b/src/components/clinical-dashboard/patient-profile-panel.tsx @@ -5,7 +5,9 @@ import { useId, useState } from "react"; import { usePatientProfile } from "@/components/clinical-dashboard/patient-profile-context"; import { cn, fieldControlPlain, fieldLabel, ToggleSwitch } from "@/components/ui-primitives"; +import { SCR_UMOL_PER_MGDL } from "@/lib/medication-patient-alerts"; import type { AllergyClass, HepaticSeverity, ScrUnit } from "@/lib/medication-patient-alerts"; +import { PATIENT_PROFILE_NUMERIC_BOUNDS, PATIENT_PROFILE_SCR_UMOL_BOUNDS } from "@/lib/patient-profile-storage"; const HEPATIC_OPTIONS: { value: HepaticSeverity; label: string }[] = [ { value: "none", label: "None" }, @@ -48,14 +50,34 @@ function NumberField({ value, onChange, testId, + min, + max, }: { label: string; unit?: string; value: number | null | undefined; onChange: (value: number | null) => void; testId?: string; + min: number; + max: number; }) { const id = useId(); + const errorId = `${id}-error`; + const [text, setText] = useState(value == null ? "" : String(value)); + const [syncedValue, setSyncedValue] = useState(value ?? null); + + // React-sanctioned "adjust state during render" reconciliation: when the stored + // value changes from outside this field (e.g. a cross-page store update), re-sync + // the buffer — but keep an in-progress out-of-range entry so its validation + // message stays visible. A profile Clear remounts the field via `key` instead + // (the stored value is already null there, so no prop change would fire here). + const parsed = parseNumber(text); + const outOfRange = parsed !== null && (parsed < min || parsed > max); + if ((value ?? null) !== syncedValue) { + setSyncedValue(value ?? null); + if (!outOfRange) setText(value == null ? "" : String(value)); + } + return (
); } @@ -87,8 +131,23 @@ export function PatientProfilePanel({ }) { const { profile, updateField, toggleAllergy, clear, isEmpty } = usePatientProfile(); const [open, setOpen] = useState(defaultOpen ?? variant === "full"); + // Bumped on Clear to remount the numeric fields, so an out-of-range entry that + // is showing a validation message (stored value already null) is reset too. + const [resetNonce, setResetNonce] = useState(0); const allergies = new Set(profile.allergies ?? []); + // Serum-creatinine validity bounds are canonical in µmol/L; convert to the + // active display unit (rounding inward so the field and the storage-layer + // check agree on the edge). Same conversion factor the alert engine uses. + const scrUnit = profile.scrUnit ?? "umol/L"; + const scrBounds = + scrUnit === "mg/dL" + ? { + min: Math.ceil((PATIENT_PROFILE_SCR_UMOL_BOUNDS.min / SCR_UMOL_PER_MGDL) * 100) / 100, + max: Math.floor((PATIENT_PROFILE_SCR_UMOL_BOUNDS.max / SCR_UMOL_PER_MGDL) * 100) / 100, + } + : PATIENT_PROFILE_SCR_UMOL_BOUNDS; + return (
updateField("ageYears", value)} testId="patient-age" + min={PATIENT_PROFILE_NUMERIC_BOUNDS.ageYears.min} + max={PATIENT_PROFILE_NUMERIC_BOUNDS.ageYears.max} /> updateField("egfr", value)} testId="patient-egfr" + min={PATIENT_PROFILE_NUMERIC_BOUNDS.egfr.min} + max={PATIENT_PROFILE_NUMERIC_BOUNDS.egfr.max} /> updateField("crcl", value)} testId="patient-crcl" + min={PATIENT_PROFILE_NUMERIC_BOUNDS.crcl.min} + max={PATIENT_PROFILE_NUMERIC_BOUNDS.crcl.max} /> updateField("qtc", value)} testId="patient-qtc" + min={PATIENT_PROFILE_NUMERIC_BOUNDS.qtc.min} + max={PATIENT_PROFILE_NUMERIC_BOUNDS.qtc.max} />
updateField("scr", value)} testId="patient-scr" + min={scrBounds.min} + max={scrBounds.max} />
@@ -236,7 +310,10 @@ export function PatientProfilePanel({