mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
1 parent
3405bb6fd3
commit
9e6a58b186
4 files changed
+530
-2
No files matched your search
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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 ─────────────────────────────────────────────────────── */
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user