fix(illness): escalate sustained fever and low SpO2 on write

A sustained fever (three calendar-consecutive days at or above the fever
red-flag floor) or sustained low SpO2 was already classified "seek care",
but the only escalation path ran inside a GET on the episode correlation
route — no push fired unless the user opened that tab, and no cron drove
it. The proactive on-write safety check covered only blood pressure and
glucose; the two signals with the strongest safety case had the weakest
delivery.

Add sustained-run temperature and SpO2 checks to the on-write
`runSafetyFloorCheck`, wired from both measurement-ingest seams. The run
length and clinical floors are shared with the retrospective detector (no
duplicated thresholds), day bucketing honours the user's timezone, and
escalation reuses the existing red-flag notification with its push-attempt
dedup so a febrile user logging repeatedly is alerted at most once per
reason per day.
This commit is contained in:
Marc-André Bombeck committed 2026-06-28 03:28:11 +02:00
1 parent 3405bb6fd3
commit 9e6a58b186
4 files changed
+530 -2

No files matched your search

+2
View File
@@ -802,6 +802,7 @@ async function postMeasurement(request: NextRequest) {
symptomsPresent: parsed.data.measurements.some(
(m) => m.symptomsPresent === true,
),
timezone: user.timezone ?? undefined,
}).catch(() => {});
// v1.5.0 — refresh the persistent rollup table for every distinct
@@ -948,6 +949,7 @@ async function postMeasurement(request: NextRequest) {
},
],
symptomsPresent,
timezone: user.timezone ?? undefined,
}).catch(() => {});
// v1.5.0 — refresh the persistent rollup row for the affected
@@ -0,0 +1,290 @@
/**
* v1.25 — on-write sustained fever / low-SpO2 escalation (finding H-CS1).
*
* The retrospective correlation detector classifies a 3-day fever or sustained
* low-SpO2 run as "seek care", but that escalation only fired when the user
* opened the episode's correlation tab. These tests pin the on-write seam:
* a sustained run escalates on INGEST through the SAME illness red-flag push,
* with the SAME run length (RED_FLAG_RUN_DAYS) and absolute floors the detector
* uses, and the 24h ledger dedupe stops a 4th reading re-firing the alarm.
*
* Calendar-day bucketing runs against TZ=UTC (the gate's contract).
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
/** In-memory pushAttempt ledger so the 24h dedupe behaves across calls. */
const ledger: Array<{ userId: string; reason: string }> = [];
const measurementFindMany = vi.fn();
const dispatchMock = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
measurement: {
findMany: (...a: unknown[]) => measurementFindMany(...a),
},
pushAttempt: {
findFirst: ({ where }: { where: { userId: string; reason: string } }) => {
const hit = ledger.find(
(r) => r.userId === where.userId && r.reason === where.reason,
);
return Promise.resolve(hit ? { id: "prior" } : null);
},
create: ({ data }: { data: { userId: string; reason: string } }) => {
ledger.push({ userId: data.userId, reason: data.reason });
return Promise.resolve({});
},
},
},
}));
vi.mock("@/lib/logging/context", () => ({
getEvent: () => ({ addMeta: vi.fn(), addWarning: vi.fn() }),
}));
vi.mock("@/lib/modules/gate", () => ({
isModuleEnabled: vi.fn().mockResolvedValue(false),
}));
vi.mock("@/lib/notifications/dispatch-localised", () => ({
dispatchLocalisedNotification: (...a: unknown[]) => dispatchMock(...a),
}));
import { runSafetyFloorCheck } from "../safety-floor-check";
/** A measured-at on a given UTC calendar day, mid-morning. */
function utcDay(day: string): Date {
return new Date(`${day}T08:00:00Z`);
}
beforeEach(() => {
ledger.length = 0;
measurementFindMany.mockReset();
dispatchMock.mockReset().mockResolvedValue(undefined);
});
afterEach(() => vi.clearAllMocks());
describe("runSafetyFloorCheck — sustained fever", () => {
it("escalates once on a 3-day calendar-consecutive fever run", async () => {
// °C ≥ 38.5 on three consecutive UTC days.
measurementFindMany.mockResolvedValue([
{ value: 39.0, measuredAt: utcDay("2026-01-03") },
{ value: 39.1, measuredAt: utcDay("2026-01-02") },
{ value: 38.8, measuredAt: utcDay("2026-01-01") },
]);
await runSafetyFloorCheck({
userId: "u1",
written: [
{
type: "BODY_TEMPERATURE",
value: 39.0,
measuredAt: utcDay("2026-01-03"),
},
],
timezone: "UTC",
});
expect(dispatchMock).toHaveBeenCalledTimes(1);
const opts = dispatchMock.mock.calls[0][0];
expect(opts.eventType).toBe("SYSTEM_ALERT");
expect(opts.urgent).toBe(true);
expect(opts.titleKey).toBe("illness.correlation.redFlagTitle");
expect(opts.messageKey).toBe("illness.correlation.redFlagFever");
});
it("does not re-fire on a 4th reading inside the dedupe window", async () => {
measurementFindMany.mockResolvedValue([
{ value: 39.0, measuredAt: utcDay("2026-01-03") },
{ value: 39.1, measuredAt: utcDay("2026-01-02") },
{ value: 38.8, measuredAt: utcDay("2026-01-01") },
]);
// First ingest — fires.
await runSafetyFloorCheck({
userId: "u1",
written: [
{
type: "BODY_TEMPERATURE",
value: 39.0,
measuredAt: utcDay("2026-01-03"),
},
],
timezone: "UTC",
});
// A later same-day reading — run still holds, but the ledger anchor dedupes.
await runSafetyFloorCheck({
userId: "u1",
written: [
{
type: "BODY_TEMPERATURE",
value: 39.2,
measuredAt: new Date("2026-01-03T20:00:00Z"),
},
],
timezone: "UTC",
});
expect(dispatchMock).toHaveBeenCalledTimes(1);
});
it("does NOT escalate on a 2-day fever run (below RED_FLAG_RUN_DAYS)", async () => {
measurementFindMany.mockResolvedValue([
{ value: 39.0, measuredAt: utcDay("2026-01-02") },
{ value: 38.9, measuredAt: utcDay("2026-01-01") },
]);
await runSafetyFloorCheck({
userId: "u1",
written: [
{
type: "BODY_TEMPERATURE",
value: 39.0,
measuredAt: utcDay("2026-01-02"),
},
],
timezone: "UTC",
});
expect(dispatchMock).not.toHaveBeenCalled();
});
it("does NOT escalate on three NON-consecutive febrile days", async () => {
measurementFindMany.mockResolvedValue([
{ value: 39.0, measuredAt: utcDay("2026-01-05") },
{ value: 39.0, measuredAt: utcDay("2026-01-03") },
{ value: 39.0, measuredAt: utcDay("2026-01-01") },
]);
await runSafetyFloorCheck({
userId: "u1",
written: [
{
type: "BODY_TEMPERATURE",
value: 39.0,
measuredAt: utcDay("2026-01-05"),
},
],
timezone: "UTC",
});
expect(dispatchMock).not.toHaveBeenCalled();
});
it("does NOT escalate when the run sits below the fever floor", async () => {
// 38.0 °C < 38.5 °C floor — elevated but not the escalation band.
measurementFindMany.mockResolvedValue([
{ value: 38.0, measuredAt: utcDay("2026-01-03") },
{ value: 38.0, measuredAt: utcDay("2026-01-02") },
{ value: 38.0, measuredAt: utcDay("2026-01-01") },
]);
await runSafetyFloorCheck({
userId: "u1",
written: [
{
type: "BODY_TEMPERATURE",
value: 38.0,
measuredAt: utcDay("2026-01-03"),
},
],
timezone: "UTC",
});
expect(dispatchMock).not.toHaveBeenCalled();
});
});
describe("runSafetyFloorCheck — sustained low SpO2", () => {
it("escalates once on a 3-day calendar-consecutive low-SpO2 run", async () => {
// ≤ 92% on three consecutive UTC days (per-day MIN is the worst).
measurementFindMany.mockResolvedValue([
{ value: 89, measuredAt: utcDay("2026-01-03") },
{ value: 91, measuredAt: utcDay("2026-01-02") },
{ value: 92, measuredAt: utcDay("2026-01-01") },
]);
await runSafetyFloorCheck({
userId: "u1",
written: [
{
type: "OXYGEN_SATURATION",
value: 89,
measuredAt: utcDay("2026-01-03"),
},
],
timezone: "UTC",
});
expect(dispatchMock).toHaveBeenCalledTimes(1);
const opts = dispatchMock.mock.calls[0][0];
expect(opts.titleKey).toBe("illness.correlation.redFlagTitle");
expect(opts.messageKey).toBe("illness.correlation.redFlagSpo2");
});
it("does NOT escalate when a single day recovers above the floor", async () => {
measurementFindMany.mockResolvedValue([
{ value: 89, measuredAt: utcDay("2026-01-03") },
{ value: 96, measuredAt: utcDay("2026-01-02") }, // breaks the run
{ value: 90, measuredAt: utcDay("2026-01-01") },
]);
await runSafetyFloorCheck({
userId: "u1",
written: [
{
type: "OXYGEN_SATURATION",
value: 89,
measuredAt: utcDay("2026-01-03"),
},
],
timezone: "UTC",
});
expect(dispatchMock).not.toHaveBeenCalled();
});
it("a fever escalation never suppresses a concurrent SpO2 one (distinct dedupe keys)", async () => {
// Two independent runs landing on the same write: both should fire once.
measurementFindMany.mockImplementation(
({ where }: { where: { type: string } }) => {
if (where.type === "BODY_TEMPERATURE") {
return Promise.resolve([
{ value: 39.0, measuredAt: utcDay("2026-01-03") },
{ value: 39.0, measuredAt: utcDay("2026-01-02") },
{ value: 39.0, measuredAt: utcDay("2026-01-01") },
]);
}
return Promise.resolve([
{ value: 89, measuredAt: utcDay("2026-01-03") },
{ value: 90, measuredAt: utcDay("2026-01-02") },
{ value: 91, measuredAt: utcDay("2026-01-01") },
]);
},
);
await runSafetyFloorCheck({
userId: "u1",
written: [
{
type: "BODY_TEMPERATURE",
value: 39.0,
measuredAt: utcDay("2026-01-03"),
},
{
type: "OXYGEN_SATURATION",
value: 89,
measuredAt: utcDay("2026-01-03"),
},
],
timezone: "UTC",
});
expect(dispatchMock).toHaveBeenCalledTimes(2);
const keys = dispatchMock.mock.calls.map((c) => c[0].messageKey).sort();
expect(keys).toEqual([
"illness.correlation.redFlagFever",
"illness.correlation.redFlagSpo2",
]);
});
});
+8 -2
View File
@@ -408,8 +408,14 @@ export interface IllnessRedFlag {
const SPO2_RED_FLAG = SPO2_RED_FLAG_PCT;
/** Body temperature at/above this (°C) for ≥ RED_FLAG_RUN_DAYS escalates. */
const FEVER_RED_FLAG = FEVER_RED_FLAG_C;
/** Consecutive days an adverse clinical threshold must hold to escalate. */
const RED_FLAG_RUN_DAYS = 3;
/**
* Consecutive days an adverse clinical threshold must hold to escalate.
* Exported so the on-write safety seam (`safety-floor-check.ts`) escalates a
* sustained fever / low-SpO2 run on ingest using the SAME run length as this
* retrospective detector — the two paths can never disagree on what "sustained"
* means.
*/
export const RED_FLAG_RUN_DAYS = 3;
/* ── pure helpers ─────────────────────────────────────────────────────── */
+230
View File
@@ -15,9 +15,11 @@
* The caller (the measurement POST route) passes the just-written rows plus
* the transient `symptomsPresent` flag; this helper does the bounded reads.
*/
import type { MeasurementType } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { getEvent } from "@/lib/logging/context";
import { isModuleEnabled } from "@/lib/modules/gate";
import { FEVER_RED_FLAG_C, SPO2_RED_FLAG_PCT } from "@/lib/clinical-floors";
import {
evaluateBloodPressure,
evaluateGlucose,
@@ -26,6 +28,13 @@ import {
type GlucoseSample,
} from "@/lib/illness/safety-floors";
import { notifySafetyFloor } from "@/lib/illness/safety-floor-notify";
import {
RED_FLAG_RUN_DAYS,
dayDiff,
type IllnessRedFlag,
} from "@/lib/illness/correlation";
import { notifyIllnessRedFlag } from "@/lib/illness/red-flag-notify";
import { userDayKey } from "@/lib/tz/format";
/** A row the write hook just persisted (the subset the check needs). */
export interface WrittenReading {
@@ -46,14 +55,30 @@ export async function runSafetyFloorCheck(input: {
userId: string;
written: WrittenReading[];
symptomsPresent?: boolean;
/**
* The user's IANA timezone, used to bucket the sustained fever / SpO2 run by
* CALENDAR day. Defaults to UTC when absent — a missing timezone must never
* silence a safety escalation, only shift the day boundary.
*/
timezone?: string;
}): Promise<void> {
const { userId, written } = input;
const symptomCoupled = input.symptomsPresent === true;
const tz =
input.timezone && input.timezone.length > 0 ? input.timezone : "UTC";
try {
await Promise.all([
checkBloodPressure(userId, written, symptomCoupled),
checkGlucose(userId, written, symptomCoupled),
// Sustained multi-day runs against ABSOLUTE clinical floors. Unlike the
// confirm-gated BP / glucose checks above (single breach + a confirming
// re-test inside an hour), a fever / low-SpO2 escalation only fires when
// the floor held for RED_FLAG_RUN_DAYS calendar-consecutive days — the
// SAME run length and floors the retrospective correlation detector uses,
// so the on-write seam and the correlation surface can never disagree.
checkSustainedFever(userId, written, tz),
checkSustainedLowSpo2(userId, written, tz),
]);
} catch (err) {
getEvent()?.addWarning(
@@ -171,3 +196,208 @@ async function checkGlucose(
const decision = evaluateGlucose({ candidate, recent, symptomCoupled });
await notifySafetyFloor({ userId, decision });
}
/* ── sustained fever / low-SpO2 (multi-day calendar runs) ─────────────── */
/**
* Lookback span (calendar days, inclusive of the candidate day) the sustained
* run scan reads. RED_FLAG_RUN_DAYS days are needed to form a run that ends on
* the candidate day; one extra day absorbs UTC-vs-local boundary skew so a run
* that ends "today" in the user's timezone is never clipped. We only react to
* RECENT runs by design — an old run is the correlation surface's job, not the
* write hook's.
*/
const SUSTAINED_LOOKBACK_DAYS = RED_FLAG_RUN_DAYS + 1;
/**
* Sustained fever: BODY_TEMPERATURE ≥ FEVER_RED_FLAG_C (°C) for
* RED_FLAG_RUN_DAYS calendar-consecutive days. Mirrors the correlation
* detector's fever path — per-day MAX is the worst (an evening spike must not
* be masked by a daily mean) and the floor + run length are the shared
* constants. Fires the SAME illness red-flag escalation the retrospective path
* fires, so the notification class matches exactly.
*/
async function checkSustainedFever(
userId: string,
written: WrittenReading[],
tz: string,
): Promise<void> {
const fresh = written.find((r) => r.type === "BODY_TEMPERATURE");
if (!fresh) return;
const days = await loadDailyWorst(
userId,
"BODY_TEMPERATURE",
fresh.measuredAt,
tz,
"max",
);
const flag = sustainedRunFlag(
days,
(v) => v >= FEVER_RED_FLAG_C,
"sustained_fever",
"BODY_TEMPERATURE",
"max",
);
if (!flag) return;
await notifyIllnessRedFlag({
userId,
episodeId: SUSTAINED_DEDUPE_KEY.sustained_fever,
redFlags: [flag],
});
}
/**
* Sustained low SpO2: OXYGEN_SATURATION ≤ SPO2_RED_FLAG_PCT for
* RED_FLAG_RUN_DAYS calendar-consecutive days. SpO2's worst is the per-day MIN,
* matching the correlation detector. Fires the SAME illness red-flag escalation.
*/
async function checkSustainedLowSpo2(
userId: string,
written: WrittenReading[],
tz: string,
): Promise<void> {
const fresh = written.find((r) => r.type === "OXYGEN_SATURATION");
if (!fresh) return;
const days = await loadDailyWorst(
userId,
"OXYGEN_SATURATION",
fresh.measuredAt,
tz,
"min",
);
const flag = sustainedRunFlag(
days,
(v) => v <= SPO2_RED_FLAG_PCT,
"sustained_low_spo2",
"OXYGEN_SATURATION",
"min",
);
if (!flag) return;
await notifyIllnessRedFlag({
userId,
episodeId: SUSTAINED_DEDUPE_KEY.sustained_low_spo2,
redFlags: [flag],
});
}
/**
* Synthetic, stable per-reason dedupe anchors. The on-write sustained checks
* may run with no active illness episode, so they cannot key the red-flag
* dedupe ledger on a real episode id. These per-reason keys ride the SAME
* `pushAttempt` ledger + 24h window `notifyIllnessRedFlag` already enforces, so
* a stubbornly febrile user logging a 4th reading inside the day re-runs the
* detector but never re-fires the alarm. Distinct keys per reason so a fever
* escalation never suppresses a concurrent SpO2 one.
*/
const SUSTAINED_DEDUPE_KEY: Record<IllnessRedFlag["reason"], string> = {
sustained_fever: "onwrite:sustained_fever",
sustained_low_spo2: "onwrite:sustained_low_spo2",
};
/** A per-day worst value, keyed by the user-timezone calendar day. */
interface DailyWorst {
day: string;
worst: number;
}
/**
* Read the recent same-type readings in the lookback window and collapse them
* to one worst value per user-timezone calendar day (MAX for fever, MIN for
* SpO2). Sorted chronologically so the run scan sees calendar order. Bounded
* read (window + row cap); the candidate's own freshly-written rows are
* INCLUDED so a run that completes on this very write escalates.
*/
async function loadDailyWorst(
userId: string,
type: MeasurementType,
candidateAt: Date,
tz: string,
worst: "min" | "max",
): Promise<DailyWorst[]> {
const windowStart = new Date(
candidateAt.getTime() - SUSTAINED_LOOKBACK_DAYS * 24 * 60 * 60 * 1000,
);
const rows = await prisma.measurement.findMany({
where: {
userId,
type,
deletedAt: null,
measuredAt: { gte: windowStart, lte: candidateAt },
},
orderBy: { measuredAt: "desc" },
take: 500,
select: { value: true, measuredAt: true },
});
const byDay = new Map<string, number>();
for (const r of rows) {
const day = userDayKey(r.measuredAt, tz);
const prev = byDay.get(day);
if (prev === undefined) {
byDay.set(day, r.value);
} else {
byDay.set(
day,
worst === "min" ? Math.min(prev, r.value) : Math.max(prev, r.value),
);
}
}
return [...byDay.entries()]
.map(([day, w]) => ({ day, worst: w }))
.sort((a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : 0));
}
/**
* Longest CALENDAR-consecutive run matching `predicate`; returns an
* {@link IllnessRedFlag} when the run reaches RED_FLAG_RUN_DAYS, else null.
* Mirrors the correlation engine's `runFlag`: present-days-only points (never
* zero-filled), so a sparse febrile series (days 01/10/15) must NOT count as
* one long run — a point only extends the run when it is the calendar day
* immediately after the previous matching one (`dayDiff === 1`).
*/
function sustainedRunFlag(
sorted: DailyWorst[],
predicate: (v: number) => boolean,
reason: IllnessRedFlag["reason"],
type: MeasurementType,
worst: "min" | "max",
): IllnessRedFlag | null {
let bestRun = 0;
let bestWorst: number | null = null;
let run = 0;
let runWorst: number | null = null;
let prevDay: string | null = null;
for (const p of sorted) {
if (predicate(p.worst)) {
const adjacent = prevDay !== null && dayDiff(prevDay, p.day) === 1;
if (adjacent) {
run++;
runWorst =
runWorst === null
? p.worst
: worst === "min"
? Math.min(runWorst, p.worst)
: Math.max(runWorst, p.worst);
} else {
run = 1;
runWorst = p.worst;
}
if (run > bestRun) {
bestRun = run;
bestWorst = runWorst;
}
prevDay = p.day;
} else {
run = 0;
runWorst = null;
prevDay = null;
}
}
if (bestRun >= RED_FLAG_RUN_DAYS && bestWorst !== null) {
return { type, reason, worstValue: bestWorst, days: bestRun };
}
return null;
}