diff --git a/messages/de.json b/messages/de.json index cdbaa9522..630ce0ac1 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6834,6 +6834,34 @@ "headache": "Kopfschmerzen", "bodyAches": "Gliederschmerzen", "fatigue": "Erschöpfung" + }, + "correlation": { + "title": "Wie es verlief", + "learning": "Noch am Lernen — erfasse ein paar weitere Tage, um hier Muster zu sehen.", + "preOnsetTitle": "Wie es sich ankündigte", + "preOnsetBody": "Werte, die vor dem Beginn von deiner Baseline abwichen.", + "nadirTitle": "Was sich änderte", + "nadirBody": "Wie weit jeder erfasste Wert von deinem typischen Bereich abwich.", + "recoveryGapTitle": "Erholungslücke", + "recoveryGapLag": "Deine Werte normalisierten sich {days} Tage, nachdem es dir besser ging.", + "recoveryGapLead": "Deine Werte normalisierten sich {days} Tage, bevor du Genesung vermerktest.", + "recoveryGapSame": "Deine Werte normalisierten sich am selben Tag, an dem es dir besser ging.", + "redFlagTitle": "Genauer ansehen", + "redFlagSpo2": "Deine Sauerstoffsättigung blieb mehrere Tage niedrig. Wenn das erneut auftritt, suche ärztliche Hilfe.", + "redFlagFever": "Du hattest anhaltend Fieber. Wenn das erneut auftritt, suche ärztliche Hilfe.", + "direction": { + "above": "über deinem Üblichen", + "below": "unter deinem Üblichen" + } + }, + "insights": { + "title": "Dein Krankheitsverlauf", + "countOne": "Du hast in diesem Zeitraum 1 Episode erfasst.", + "countOther": "Du hast in diesem Zeitraum {count} Episoden erfasst.", + "typicalGap": "Typische Erholungslücke: {days} Tage zwischen Besserung und normalisierten Werten.", + "typicalGapLearning": "Noch ein paar abgeschlossene Episoden und hier erscheint eine typische Erholungslücke.", + "byMonth": "Wann du Episoden erfasst", + "none": "In diesem Zeitraum keine Episoden erfasst." } }, "coach": { diff --git a/messages/en.json b/messages/en.json index 7ca9be83e..1dd848fbb 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6834,6 +6834,34 @@ "headache": "Headache", "bodyAches": "Body aches", "fatigue": "Fatigue" + }, + "correlation": { + "title": "How it unfolded", + "learning": "Still learning — log a few more days to see patterns here.", + "preOnsetTitle": "How it announced itself", + "preOnsetBody": "Signals that drifted from your baseline before you marked the onset.", + "nadirTitle": "What changed", + "nadirBody": "How far each tracked signal strayed from your typical range.", + "recoveryGapTitle": "Recovery gap", + "recoveryGapLag": "Your numbers settled {days} days after you felt better.", + "recoveryGapLead": "Your numbers settled {days} days before you marked recovery.", + "recoveryGapSame": "Your numbers settled the same day you felt better.", + "redFlagTitle": "Worth a closer look", + "redFlagSpo2": "Your oxygen saturation stayed low for several days. If this recurs, seek medical care.", + "redFlagFever": "You ran a sustained fever. If this recurs, seek medical care.", + "direction": { + "above": "above your usual", + "below": "below your usual" + } + }, + "insights": { + "title": "Your illness history", + "countOne": "You logged 1 episode in this period.", + "countOther": "You logged {count} episodes in this period.", + "typicalGap": "Typical recovery gap: {days} days between feeling better and your numbers settling.", + "typicalGapLearning": "A few more resolved episodes and a typical recovery gap will appear here.", + "byMonth": "When you tend to log episodes", + "none": "No episodes logged in this period." } }, "coach": { diff --git a/messages/es.json b/messages/es.json index 4edefab7d..6d909c863 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6834,6 +6834,34 @@ "headache": "Dolor de cabeza", "bodyAches": "Dolores musculares", "fatigue": "Fatiga" + }, + "correlation": { + "title": "Cómo se desarrolló", + "learning": "Aún aprendiendo: registra unos días más para ver patrones aquí.", + "preOnsetTitle": "Cómo se anunció", + "preOnsetBody": "Señales que se alejaron de tu base antes de marcar el inicio.", + "nadirTitle": "Qué cambió", + "nadirBody": "Cuánto se alejó cada señal registrada de tu rango habitual.", + "recoveryGapTitle": "Brecha de recuperación", + "recoveryGapLag": "Tus valores se estabilizaron {days} días después de sentirte mejor.", + "recoveryGapLead": "Tus valores se estabilizaron {days} días antes de marcar la recuperación.", + "recoveryGapSame": "Tus valores se estabilizaron el mismo día en que te sentiste mejor.", + "redFlagTitle": "Conviene revisarlo", + "redFlagSpo2": "Tu saturación de oxígeno se mantuvo baja varios días. Si se repite, busca atención médica.", + "redFlagFever": "Tuviste fiebre sostenida. Si se repite, busca atención médica.", + "direction": { + "above": "por encima de lo habitual", + "below": "por debajo de lo habitual" + } + }, + "insights": { + "title": "Tu historial de enfermedades", + "countOne": "Registraste 1 episodio en este período.", + "countOther": "Registraste {count} episodios en este período.", + "typicalGap": "Brecha de recuperación típica: {days} días entre sentirte mejor y la estabilización de tus valores.", + "typicalGapLearning": "Con unos pocos episodios resueltos más aparecerá aquí una brecha de recuperación típica.", + "byMonth": "Cuándo sueles registrar episodios", + "none": "Sin episodios registrados en este período." } }, "coach": { diff --git a/messages/fr.json b/messages/fr.json index e2644cd04..b86158944 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6834,6 +6834,34 @@ "headache": "Mal de tête", "bodyAches": "Courbatures", "fatigue": "Fatigue" + }, + "correlation": { + "title": "Comment cela s'est déroulé", + "learning": "Encore en apprentissage — enregistre quelques jours de plus pour voir des tendances ici.", + "preOnsetTitle": "Comment cela s'est annoncé", + "preOnsetBody": "Signaux qui se sont écartés de ta base avant le début que tu as noté.", + "nadirTitle": "Ce qui a changé", + "nadirBody": "À quel point chaque signal suivi s'est écarté de ta plage habituelle.", + "recoveryGapTitle": "Écart de récupération", + "recoveryGapLag": "Tes valeurs se sont stabilisées {days} jours après que tu te sois senti mieux.", + "recoveryGapLead": "Tes valeurs se sont stabilisées {days} jours avant que tu notes la guérison.", + "recoveryGapSame": "Tes valeurs se sont stabilisées le jour même où tu t'es senti mieux.", + "redFlagTitle": "À examiner de plus près", + "redFlagSpo2": "Ta saturation en oxygène est restée basse plusieurs jours. Si cela se reproduit, consulte un médecin.", + "redFlagFever": "Tu as eu une fièvre persistante. Si cela se reproduit, consulte un médecin.", + "direction": { + "above": "au-dessus de ton habituel", + "below": "en dessous de ton habituel" + } + }, + "insights": { + "title": "Ton historique de maladies", + "countOne": "Tu as enregistré 1 épisode sur cette période.", + "countOther": "Tu as enregistré {count} épisodes sur cette période.", + "typicalGap": "Écart de récupération typique : {days} jours entre le mieux-être et la stabilisation de tes valeurs.", + "typicalGapLearning": "Encore quelques épisodes résolus et un écart de récupération typique apparaîtra ici.", + "byMonth": "Quand tu enregistres des épisodes", + "none": "Aucun épisode enregistré sur cette période." } }, "coach": { diff --git a/messages/it.json b/messages/it.json index e6c673f46..387f81694 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6834,6 +6834,34 @@ "headache": "Mal di testa", "bodyAches": "Dolori muscolari", "fatigue": "Affaticamento" + }, + "correlation": { + "title": "Come si è svolto", + "learning": "Ancora in apprendimento: registra qualche giorno in più per vedere i pattern qui.", + "preOnsetTitle": "Come si è annunciato", + "preOnsetBody": "Segnali che si sono allontanati dalla tua base prima dell'inizio che hai segnato.", + "nadirTitle": "Cosa è cambiato", + "nadirBody": "Quanto ogni segnale registrato si è allontanato dal tuo intervallo abituale.", + "recoveryGapTitle": "Divario di recupero", + "recoveryGapLag": "I tuoi valori si sono stabilizzati {days} giorni dopo esserti sentito meglio.", + "recoveryGapLead": "I tuoi valori si sono stabilizzati {days} giorni prima di segnare la guarigione.", + "recoveryGapSame": "I tuoi valori si sono stabilizzati lo stesso giorno in cui ti sei sentito meglio.", + "redFlagTitle": "Da guardare più da vicino", + "redFlagSpo2": "La tua saturazione di ossigeno è rimasta bassa per diversi giorni. Se si ripete, rivolgiti a un medico.", + "redFlagFever": "Hai avuto febbre persistente. Se si ripete, rivolgiti a un medico.", + "direction": { + "above": "sopra il tuo solito", + "below": "sotto il tuo solito" + } + }, + "insights": { + "title": "La tua storia di malattie", + "countOne": "Hai registrato 1 episodio in questo periodo.", + "countOther": "Hai registrato {count} episodi in questo periodo.", + "typicalGap": "Divario di recupero tipico: {days} giorni tra il sentirti meglio e la stabilizzazione dei valori.", + "typicalGapLearning": "Ancora qualche episodio risolto e qui apparirà un divario di recupero tipico.", + "byMonth": "Quando tendi a registrare episodi", + "none": "Nessun episodio registrato in questo periodo." } }, "coach": { diff --git a/messages/pl.json b/messages/pl.json index 34d735c60..a1b6d1d8c 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6834,6 +6834,34 @@ "headache": "Ból głowy", "bodyAches": "Bóle ciała", "fatigue": "Zmęczenie" + }, + "correlation": { + "title": "Jak przebiegało", + "learning": "Wciąż się uczę — zapisz jeszcze kilka dni, aby zobaczyć tu wzorce.", + "preOnsetTitle": "Jak się zapowiadało", + "preOnsetBody": "Sygnały, które odbiegły od Twojej bazy przed oznaczonym początkiem.", + "nadirTitle": "Co się zmieniło", + "nadirBody": "Jak daleko każdy śledzony sygnał odbiegł od Twojego typowego zakresu.", + "recoveryGapTitle": "Luka rekonwalescencji", + "recoveryGapLag": "Twoje wartości ustabilizowały się {days} dni po tym, jak poczułeś się lepiej.", + "recoveryGapLead": "Twoje wartości ustabilizowały się {days} dni przed oznaczeniem wyzdrowienia.", + "recoveryGapSame": "Twoje wartości ustabilizowały się tego samego dnia, gdy poczułeś się lepiej.", + "redFlagTitle": "Warto przyjrzeć się bliżej", + "redFlagSpo2": "Twoje wysycenie tlenem pozostawało niskie przez kilka dni. Jeśli się powtórzy, zasięgnij pomocy medycznej.", + "redFlagFever": "Miałeś utrzymującą się gorączkę. Jeśli się powtórzy, zasięgnij pomocy medycznej.", + "direction": { + "above": "powyżej Twojej normy", + "below": "poniżej Twojej normy" + } + }, + "insights": { + "title": "Twoja historia chorób", + "countOne": "W tym okresie zapisałeś 1 epizod.", + "countOther": "W tym okresie zapisałeś {count} epizodów.", + "typicalGap": "Typowa luka rekonwalescencji: {days} dni między poprawą a ustabilizowaniem wartości.", + "typicalGapLearning": "Jeszcze kilka zakończonych epizodów i pojawi się tu typowa luka rekonwalescencji.", + "byMonth": "Kiedy zwykle zapisujesz epizody", + "none": "Brak epizodów zapisanych w tym okresie." } }, "coach": { diff --git a/src/app/api/illness/episodes/[id]/correlation/route.ts b/src/app/api/illness/episodes/[id]/correlation/route.ts new file mode 100644 index 000000000..a315f69ae --- /dev/null +++ b/src/app/api/illness/episodes/[id]/correlation/route.ts @@ -0,0 +1,87 @@ +/** + * `GET /api/illness/episodes/{id}/correlation` — the retrospective correlation + * findings for one episode: the pre-onset anomaly scan ("how did it announce + * itself"), the nadir / what-dropped, and the recovery-gap (physiological + * return-to-baseline vs the felt-better marker). + * + * Server-authoritative + coverage-gated: the response is a `Derived` flat + * wire shape (`status: "ok" | "insufficient"`). When the signal is thin the + * engine returns `insufficient` with coverage + a reason — the surface renders + * "we're still learning", never a fabricated number. The findings are derived + * from the user's OWN baseline (median ± MAD), never a population constant, and + * the baseline window is contamination-guarded (ends before the pre-onset + * lookback). Retrospective ONLY — never a predictor or diagnoser. Born-gated + + * owner-scoped. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { apiSuccess, apiError } from "@/lib/api-response"; +import { requireIllnessEnabled } from "@/lib/illness/gate"; +import { computeEpisodeCorrelation } from "@/lib/illness/correlation-read"; +import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; + +type RouteParams = { params: Promise<{ id: string }> }; + +export const GET = apiHandler( + async (_request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + + const gate = await requireIllnessEnabled(user.id); + if (!gate.enabled) return gate.response; + + const { id } = await params; + const episode = await prisma.illnessEpisode.findUnique({ + where: { id }, + select: { + id: true, + userId: true, + deletedAt: true, + onsetAt: true, + resolvedAt: true, + lifecycle: true, + }, + }); + if (!episode || episode.userId !== user.id || episode.deletedAt !== null) { + return apiError("Episode not found", 404); + } + + const tz = user.timezone ?? DEFAULT_TIMEZONE; + const derived = await computeEpisodeCorrelation( + user.id, + { + id: episode.id, + onsetAt: episode.onsetAt, + resolvedAt: episode.resolvedAt, + lifecycle: episode.lifecycle, + }, + tz, + ); + + annotate({ + action: { + name: "illness.correlation.read", + entity_type: "illness_episode", + entity_id: id, + }, + meta: { + status: derived.status, + recovery_gap_days: + derived.status === "ok" ? derived.value.recoveryGapDays : null, + red_flags: derived.status === "ok" ? derived.value.redFlags.length : 0, + }, + }); + + return apiSuccess({ + episodeId: id, + status: derived.status, + value: derived.status === "ok" ? derived.value : null, + coverage: derived.coverage, + confidence: derived.status === "ok" ? derived.confidence : null, + provenance: derived.provenance, + reason: derived.status === "insufficient" ? derived.reason : null, + }); + }, +); diff --git a/src/app/api/illness/insights/route.ts b/src/app/api/illness/insights/route.ts new file mode 100644 index 000000000..48960abdd --- /dev/null +++ b/src/app/api/illness/insights/route.ts @@ -0,0 +1,107 @@ +/** + * `GET /api/illness/insights?windowDays=365` — the cross-episode retrospective + * summary: "sick N times this year · typical recovery gap X days", with a + * recurrence-by-month tally and a per-type breakdown. + * + * RETROSPECTIVE ONLY — it summarises past episodes. It is NEVER a predictor or + * diagnoser; the recurrence figure is a count, not a forecast. Server- + * authoritative + gated: the "typical gap" is withheld below the min-sample + * floor (asserts nothing thin). Per-episode gaps come from the same coverage- + * gated correlation engine the per-episode route uses, so a thin episode + * contributes no gap rather than a wrong one. Born-gated + owner-scoped. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { apiSuccess, returnAllZodIssues } from "@/lib/api-response"; +import { requireIllnessEnabled } from "@/lib/illness/gate"; +import { illnessInsightsQuerySchema } from "@/lib/validations/illness"; +import { computeEpisodeCorrelation } from "@/lib/illness/correlation-read"; +import { + summarizeIllnessRetrospective, + type RetrospectiveEpisode, +} from "@/lib/illness/retrospective"; +import { dayKeyForUserTz } from "@/lib/measurements/consolidation-tz"; +import { resolveUserTimezone } from "@/lib/measurements/consolidation-base"; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; +/** Cap the per-episode gap computations so a heavy history can't fan out. */ +const MAX_GAP_COMPUTATIONS = 24; + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const gate = await requireIllnessEnabled(user.id); + if (!gate.enabled) return gate.response; + + const parsed = illnessInsightsQuerySchema.safeParse({ + windowDays: new URL(request.url).searchParams.get("windowDays") ?? undefined, + }); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "illness.insights.invalid", + }); + } + + const windowDays = parsed.data.windowDays ?? 365; + const now = new Date(); + const since = new Date(now.getTime() - windowDays * MS_PER_DAY); + const tz = resolveUserTimezone(user.timezone ?? null); + + const rows = await prisma.illnessEpisode.findMany({ + where: { userId: user.id, deletedAt: null, onsetAt: { gte: since } }, + orderBy: { onsetAt: "desc" }, + select: { + id: true, + type: true, + lifecycle: true, + onsetAt: true, + resolvedAt: true, + }, + }); + + // Compute the recovery-gap only for resolved, non-chronic episodes (the + // only ones that CAN have a gap), bounded to MAX_GAP_COMPUTATIONS most + // recent. Each gap comes from the coverage-gated engine — a thin episode + // yields null and simply doesn't contribute to the typical-gap median. + const gapCandidates = rows + .filter((r) => r.resolvedAt !== null && r.lifecycle !== "CHRONIC_ONGOING") + .slice(0, MAX_GAP_COMPUTATIONS); + const gapById = new Map(); + for (const r of gapCandidates) { + const derived = await computeEpisodeCorrelation( + user.id, + { id: r.id, onsetAt: r.onsetAt, resolvedAt: r.resolvedAt, lifecycle: r.lifecycle }, + tz, + now, + ); + gapById.set( + r.id, + derived.status === "ok" ? derived.value.recoveryGapDays : null, + ); + } + + const episodes: RetrospectiveEpisode[] = rows.map((r) => ({ + id: r.id, + type: r.type, + onsetDay: dayKeyForUserTz(r.onsetAt, tz), + resolved: r.resolvedAt !== null, + recoveryGapDays: gapById.get(r.id) ?? null, + lifecycle: r.lifecycle, + })); + + const summary = summarizeIllnessRetrospective(episodes); + + annotate({ + action: { name: "illness.insights.read", entity_type: "illness_episode" }, + meta: { + episode_count: summary.episodeCount, + gap_sample_size: summary.gapSampleSize, + typical_gap_days: summary.typicalRecoveryGapDays, + }, + }); + + return apiSuccess({ windowDays, ...summary }); +}); diff --git a/src/lib/illness/__tests__/correlation-read.test.ts b/src/lib/illness/__tests__/correlation-read.test.ts new file mode 100644 index 000000000..01c1625a5 --- /dev/null +++ b/src/lib/illness/__tests__/correlation-read.test.ts @@ -0,0 +1,210 @@ +/** + * v1.18.1 P3 — illness correlation READ layer + rollup-vs-live parity. + * + * The reliability anchor: the same per-day series fed to the engine must + * produce IDENTICAL findings whether it was resolved from the rollup DAY tier + * or the live-SQL fallback. We drive `computeEpisodeCorrelation` twice over the + * SAME underlying daily data — once with full rollup coverage (the DAY path), + * once with a coverage miss (the live raw-read path) — and assert the engine + * output is byte-identical (modulo the provenance `source` tag). + * + * Also pins the contamination-guard READ window: the baseline read window must + * end strictly before the pre-onset lookback (no episode-span day reaches the + * baseline query). + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +/* The DAY-native daily means the test universe holds for RESTING_HEART_RATE. + * Baseline window sits at ~55; the episode-span (from 01-03 onward) spikes. + * Onset 2026-01-10, lookback 7 → pre-onset starts 2026-01-03; baseline window + * is the 30 days ending 2026-01-03. */ +const DAILY: Array<{ day: string; mean: number }> = []; +{ + // 30 clean baseline days ending the day before 2026-01-03. + let cursor = Date.parse("2026-01-02T00:00:00Z"); + // Even spread around 55 so the MAD (and band) is non-zero and stable. + const offsets = [-2, -1, 0, 1, 2]; + for (let i = 0; i < 30; i++) { + DAILY.unshift({ + day: new Date(cursor).toISOString().slice(0, 10), + mean: 55 + offsets[i % offsets.length], + }); + cursor -= 24 * 60 * 60 * 1000; + } + // Episode span. + for (const [day, mean] of [ + ["2026-01-03", 56], + ["2026-01-08", 70], // pre-onset notable + ["2026-01-10", 74], + ["2026-01-12", 76], // nadir + ["2026-01-15", 64], + ["2026-01-17", 58], + ["2026-01-18", 55], + ["2026-01-19", 55], + ["2026-01-20", 55], + ] as const) { + DAILY.push({ day, mean }); + } +} + +const NOW = new Date("2026-01-21T12:00:00Z"); + +const db = vi.hoisted(() => ({ + measurement: { findMany: vi.fn() }, +})); +const rollup = vi.hoisted(() => ({ + probeRollupCoverage: vi.fn(), + readBestGranularityRollups: vi.fn(), +})); + +vi.mock("@/lib/db", () => ({ prisma: db })); +vi.mock("@/lib/rollups/measurement-coverage", () => ({ + probeRollupCoverage: rollup.probeRollupCoverage, +})); +vi.mock("@/lib/rollups/measurement-read-wmy", () => ({ + readBestGranularityRollups: rollup.readBestGranularityRollups, +})); + +import { computeEpisodeCorrelation } from "../correlation-read"; +import { ILLNESS_SCAN_TYPES } from "../correlation"; + +/** + * An ACTIVE episode (no resolvedAt) so the episode-window read's `to` is + * effectively `now` — this exercises the trailing rollup DAY path in the + * covered case and the trailing live path in the miss case, the exact seam + * the parity test must pin. The baseline window stays bounded (raw both ways). + */ +const EPISODE = { + id: "ep-parity", + onsetAt: new Date("2026-01-10T08:00:00Z"), + resolvedAt: null as Date | null, + lifecycle: "ACUTE", +}; + +/** Daily rows within [from, to) as a raw-measurement findMany result. */ +function rawRowsWithin(from: Date, to: Date) { + const fromKey = from.toISOString().slice(0, 10); + const toKey = to.toISOString().slice(0, 10); + return DAILY.filter((d) => d.day >= fromKey && d.day < toKey).map((d) => ({ + value: d.mean, + measuredAt: new Date(`${d.day}T12:00:00Z`), + })); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("computeEpisodeCorrelation — rollup-vs-live parity", () => { + function onlyRhr(map: Map): Map { + return map; + } + + it("produces identical findings on the DAY path and the live path", async () => { + // ── DAY path: full coverage for RESTING_HEART_RATE, rollup serves the + // trailing (to == now) episode-window read; the bounded baseline read + // goes through measurement.findMany (to != now). ── + const dayCoverage = onlyRhr( + new Map(ILLNESS_SCAN_TYPES.map((t) => [String(t), t === "RESTING_HEART_RATE"])), + ); + rollup.probeRollupCoverage.mockResolvedValue(dayCoverage); + rollup.readBestGranularityRollups.mockImplementation( + async (_u: string, type: string, windowDays: number) => { + if (type !== "RESTING_HEART_RATE") return null; + const since = new Date(NOW.getTime() - windowDays * 24 * 60 * 60 * 1000); + const sinceKey = since.toISOString().slice(0, 10); + const rows = DAILY.filter((d) => d.day >= sinceKey).map((d) => ({ + bucketStart: new Date(`${d.day}T00:00:00Z`), + count: 1, + mean: d.mean, + sd: null, + slope: null, + r2: null, + sumValue: null, + minValue: d.mean, + maxValue: d.mean, + })); + return rows.length > 0 ? { granularity: "DAY" as const, rows } : null; + }, + ); + db.measurement.findMany.mockImplementation(async ({ where }: { where: { type: string; measuredAt: { gte: Date; lt: Date } } }) => { + if (where.type !== "RESTING_HEART_RATE") return []; + return rawRowsWithin(where.measuredAt.gte, where.measuredAt.lt); + }); + + const dayOut = await computeEpisodeCorrelation("u1", EPISODE, "UTC", NOW); + + // ── Live path: zero rollup coverage, every read falls to findMany. The + // baseline engine's live fallback groups raw rows itself for the + // trailing read; we serve the same DAILY universe for any window. ── + vi.clearAllMocks(); + const noCoverage = new Map( + ILLNESS_SCAN_TYPES.map((t) => [String(t), false]), + ); + rollup.probeRollupCoverage.mockResolvedValue(noCoverage); + rollup.readBestGranularityRollups.mockResolvedValue(null); + db.measurement.findMany.mockImplementation( + async ({ where }: { where: { type: string; measuredAt: { gte: Date; lt?: Date } } }) => { + if (where.type !== "RESTING_HEART_RATE") return []; + const from = where.measuredAt.gte; + const to = where.measuredAt.lt ?? NOW; + return rawRowsWithin(from, to); + }, + ); + + const liveOut = await computeEpisodeCorrelation("u1", EPISODE, "UTC", NOW); + + expect(dayOut.status).toBe("ok"); + expect(liveOut.status).toBe("ok"); + if (dayOut.status !== "ok" || liveOut.status !== "ok") return; + + // Same numbers regardless of read path (the parity guarantee): the + // DAY-rollup-fed series and the live-SQL series produce byte-identical + // findings. + expect(liveOut.value.recoveryGapDays).toBe(dayOut.value.recoveryGapDays); + expect(liveOut.value.nadir).toEqual(dayOut.value.nadir); + expect(liveOut.value.preOnset).toEqual(dayOut.value.preOnset); + expect(liveOut.value.returns).toEqual(dayOut.value.returns); + + // And the findings are the expected golden values. + expect(dayOut.value.nadir[0]?.day).toBe("2026-01-12"); + expect(dayOut.value.nadir[0]?.value).toBe(76); + expect(dayOut.value.preOnset[0]?.day).toBe("2026-01-08"); + // Active episode → no felt-better marker → the gap is null but the + // physiological return is still detected (01-17 is the first in-band day + // that then holds for ≥3 observed days). + expect(dayOut.value.recoveryGapDays).toBeNull(); + expect(dayOut.value.returns[0]?.returnedDay).toBe("2026-01-17"); + }); +}); + +describe("computeEpisodeCorrelation — contamination-guard read window", () => { + it("never queries baseline rows on or after the pre-onset lookback start", async () => { + rollup.probeRollupCoverage.mockResolvedValue( + new Map(ILLNESS_SCAN_TYPES.map((t) => [String(t), false])), + ); + rollup.readBestGranularityRollups.mockResolvedValue(null); + const seen: Array<{ gte: Date; lt?: Date }> = []; + db.measurement.findMany.mockImplementation( + async ({ where }: { where: { type: string; measuredAt: { gte: Date; lt?: Date } } }) => { + if (where.type === "RESTING_HEART_RATE") seen.push(where.measuredAt); + return []; + }, + ); + + await computeEpisodeCorrelation("u1", EPISODE, "UTC", NOW); + + // The pre-onset lookback starts at onset − 7 days = 2026-01-03. + const lookbackStart = Date.parse("2026-01-03T08:00:00Z"); + // The bounded baseline read (the one with a `lt`) must END at/ before + // the lookback start — no baseline query may reach into the episode span. + const boundedBaseline = seen.filter((w) => w.lt !== undefined); + expect(boundedBaseline.length).toBeGreaterThan(0); + for (const w of boundedBaseline) { + expect(w.lt!.getTime()).toBeLessThanOrEqual(lookbackStart); + } + }); +}); diff --git a/src/lib/illness/__tests__/correlation.test.ts b/src/lib/illness/__tests__/correlation.test.ts new file mode 100644 index 000000000..5c627d887 --- /dev/null +++ b/src/lib/illness/__tests__/correlation.test.ts @@ -0,0 +1,294 @@ +/** + * v1.18.1 P3 — illness correlation engine (the pure math). + * + * The reliability battery for `computeIllnessCorrelation`: + * - golden-fixture recovery-gap math (felt-better vs physiological return) + * - baseline-contamination guard (episode days must not poison the baseline) + * - thin-data / coverage-gate (asserts NOTHING below the floor) + * - pre-onset anomaly scan + nadir direction (adverse vs not) + * - red-flag escalation (sustained SpO2 drop / fever) + * - CHRONIC_ONGOING exclusion from the gap + * + * Pure — no DB. Series are hand-built so each finding is exactly predictable. + */ +import { describe, expect, it } from "vitest"; +import type { MeasurementType } from "@/generated/prisma/client"; +import { + computeIllnessCorrelation, + dayDiff, + MIN_BASELINE_DAYS, + type IllnessCorrelationInput, + type VitalDayPoint, +} from "../correlation"; + +const NOW = new Date("2026-02-01T00:00:00Z"); + +/** Build a flat baseline series of `days` days at `value`, ending `endDay`. */ +function flatBaseline(value: number, days: number, endDay: string): VitalDayPoint[] { + const out: VitalDayPoint[] = []; + let cursor = Date.parse(`${endDay}T00:00:00Z`); + for (let i = 0; i < days; i++) { + out.unshift({ day: new Date(cursor).toISOString().slice(0, 10), mean: value }); + cursor -= 24 * 60 * 60 * 1000; + } + return out; +} + +/** + * A baseline whose per-day means spread evenly so the MAD (and thus the band) + * is non-zero and stable. Means cycle through `center − jitter … center + + * jitter` so the median is `center` and the MAD is ~`jitter/2`. + */ +function jitteredBaseline(center: number, jitter: number, days: number, endDay: string): VitalDayPoint[] { + const base = flatBaseline(center, days, endDay); + const offsets = [-jitter, -jitter / 2, 0, jitter / 2, jitter]; + return base.map((p, i) => ({ ...p, mean: center + offsets[i % offsets.length] })); +} + +function input(over: Partial): IllnessCorrelationInput { + return { + episodeId: "ep1", + window: { onsetDay: "2026-01-10", feltBetterDay: "2026-01-17", lifecycle: "ACUTE" }, + series: [], + source: "DAY", + now: NOW, + ...over, + }; +} + +describe("computeIllnessCorrelation — coverage gate", () => { + it("returns insufficient with no baselined vitals", () => { + const out = computeIllnessCorrelation(input({ series: [] })); + expect(out.status).toBe("insufficient"); + if (out.status === "insufficient") expect(out.reason).toBe("no_baselined_vitals"); + }); + + it("returns insufficient when a vital lacks the min baseline days", () => { + const type: MeasurementType = "RESTING_HEART_RATE"; + const out = computeIllnessCorrelation( + input({ + series: [ + { + type, + baselineDays: flatBaseline(55, MIN_BASELINE_DAYS - 1, "2026-01-02"), + episodeDays: flatBaseline(55, 6, "2026-01-15"), + }, + ], + }), + ); + expect(out.status).toBe("insufficient"); + }); + + it("returns insufficient when episode coverage is below the floor", () => { + const type: MeasurementType = "RESTING_HEART_RATE"; + const out = computeIllnessCorrelation( + input({ + series: [ + { + type, + baselineDays: jitteredBaseline(55, 1, 14, "2026-01-02"), + episodeDays: flatBaseline(55, 2, "2026-01-11"), // only 2 episode days + }, + ], + }), + ); + expect(out.status).toBe("insufficient"); + if (out.status === "insufficient") + expect(out.reason).toBe("insufficient_episode_coverage"); + }); +}); + +describe("computeIllnessCorrelation — golden recovery-gap", () => { + it("computes a positive gap when the body lags the felt-better marker", () => { + // RHR baseline 55, MAD-band ≈ ±4.4 (spread ≈ 2.22, NOTABLE = 2σ). Onset + // 01-10, felt better 01-17. RHR spikes to 75, stays clearly out-of-band + // (≥62) through 01-19, then re-enters the band and holds from 01-20. + const type: MeasurementType = "RESTING_HEART_RATE"; + const baselineDays = jitteredBaseline(55, 1, 21, "2026-01-02"); + const episodeDays: VitalDayPoint[] = [ + { day: "2026-01-09", mean: 55 }, // pre-onset, in band + { day: "2026-01-10", mean: 70 }, + { day: "2026-01-12", mean: 75 }, // nadir + { day: "2026-01-15", mean: 68 }, + { day: "2026-01-17", mean: 64 }, // felt-better day, still out-of-band + { day: "2026-01-19", mean: 62 }, // still out-of-band + { day: "2026-01-20", mean: 55 }, // return start (in band) + { day: "2026-01-21", mean: 55 }, + { day: "2026-01-22", mean: 55 }, // stable 3 days + ]; + const out = computeIllnessCorrelation( + input({ series: [{ type, baselineDays, episodeDays }] }), + ); + expect(out.status).toBe("ok"); + if (out.status !== "ok") return; + // returned 01-20, felt better 01-17 → gap +3 + expect(out.value.recoveryGapDays).toBe(dayDiff("2026-01-17", "2026-01-20")); + expect(out.value.recoveryGapDays).toBe(3); + // nadir at 01-12, value 75, adverse (RHR up = adverse) + expect(out.value.nadir[0]?.day).toBe("2026-01-12"); + expect(out.value.nadir[0]?.value).toBe(75); + expect(out.value.nadir[0]?.adverse).toBe(true); + expect(out.value.nadir[0]?.direction).toBe("above"); + }); + + it("flags a pre-onset anomaly when the body deviated before onset", () => { + const type: MeasurementType = "HEART_RATE_VARIABILITY"; + // HRV baseline 60±2. Drops to 30 two days before onset → adverse (down). + const baselineDays = jitteredBaseline(60, 2, 21, "2026-01-02"); + const episodeDays: VitalDayPoint[] = [ + { day: "2026-01-08", mean: 30 }, // pre-onset crash (notable) + { day: "2026-01-10", mean: 28 }, + { day: "2026-01-12", mean: 32 }, + { day: "2026-01-14", mean: 45 }, + { day: "2026-01-15", mean: 60 }, + ]; + const out = computeIllnessCorrelation( + input({ series: [{ type, baselineDays, episodeDays }] }), + ); + expect(out.status).toBe("ok"); + if (out.status !== "ok") return; + expect(out.value.preOnset[0]?.day).toBe("2026-01-08"); + expect(out.value.preOnset[0]?.direction).toBe("below"); + expect(out.value.preOnset[0]?.adverse).toBe(true); + }); + + it("excludes CHRONIC_ONGOING from the recovery-gap", () => { + const type: MeasurementType = "RESTING_HEART_RATE"; + const baselineDays = jitteredBaseline(55, 1, 21, "2026-01-02"); + const episodeDays: VitalDayPoint[] = [ + { day: "2026-01-10", mean: 70 }, + { day: "2026-01-12", mean: 75 }, + { day: "2026-01-15", mean: 55 }, + { day: "2026-01-16", mean: 55 }, + { day: "2026-01-17", mean: 55 }, + ]; + const out = computeIllnessCorrelation( + input({ + window: { onsetDay: "2026-01-10", feltBetterDay: "2026-01-15", lifecycle: "CHRONIC_ONGOING" }, + series: [{ type, baselineDays, episodeDays }], + }), + ); + expect(out.status).toBe("ok"); + if (out.status !== "ok") return; + expect(out.value.recoveryGapDays).toBeNull(); + expect(out.value.returns).toHaveLength(0); + }); + + it("leaves the gap null when the vital never stably returns (still active)", () => { + const type: MeasurementType = "RESTING_HEART_RATE"; + const baselineDays = jitteredBaseline(55, 1, 21, "2026-01-02"); + const episodeDays: VitalDayPoint[] = [ + { day: "2026-01-10", mean: 72 }, + { day: "2026-01-12", mean: 75 }, + { day: "2026-01-14", mean: 73 }, + { day: "2026-01-16", mean: 70 }, // never returns to band + ]; + const out = computeIllnessCorrelation( + input({ + window: { onsetDay: "2026-01-10", feltBetterDay: null, lifecycle: "ACUTE" }, + series: [{ type, baselineDays, episodeDays }], + }), + ); + expect(out.status).toBe("ok"); + if (out.status !== "ok") return; + expect(out.value.recoveryGapDays).toBeNull(); + expect(out.value.returns[0]?.returnedDay).toBeNull(); + }); +}); + +describe("computeIllnessCorrelation — baseline-contamination guard", () => { + it("uses ONLY baselineDays for the band, never the episode-span values", () => { + // If episode-span spikes contaminated the band, the 75 readings would + // widen the band and the deviation would shrink. We assert the band is + // built purely from baselineDays by comparing two episodes whose + // baselineDays are identical but episodeDays differ wildly — the nadir + // deviationSd for the SAME nadir value must be identical. + const type: MeasurementType = "RESTING_HEART_RATE"; + const baselineDays = jitteredBaseline(55, 1, 21, "2026-01-02"); + + const calm = computeIllnessCorrelation( + input({ + series: [ + { + type, + baselineDays, + episodeDays: [ + { day: "2026-01-10", mean: 56 }, + { day: "2026-01-11", mean: 75 }, // single nadir + { day: "2026-01-12", mean: 56 }, + { day: "2026-01-13", mean: 55 }, + ], + }, + ], + }), + ); + const stormy = computeIllnessCorrelation( + input({ + series: [ + { + type, + baselineDays, + episodeDays: [ + { day: "2026-01-10", mean: 90 }, + { day: "2026-01-11", mean: 75 }, // SAME nadir value as calm + { day: "2026-01-12", mean: 88 }, + { day: "2026-01-13", mean: 92 }, + ], + }, + ], + }), + ); + expect(calm.status).toBe("ok"); + expect(stormy.status).toBe("ok"); + if (calm.status !== "ok" || stormy.status !== "ok") return; + // The band center is the clean baseline median (55) in BOTH episodes — + // the wildly different episode-span values never shift it. A contaminated + // baseline (one that ingested the 75–92 readings) would pull the center + // upward and the two centers would diverge. + const calmCenter = calm.value.nadir[0]?.baselineCenter; + const stormyCenter = stormy.value.nadir[0]?.baselineCenter; + expect(calmCenter).toBe(55); + expect(stormyCenter).toBe(55); + expect(stormyCenter).toBe(calmCenter); + }); +}); + +describe("computeIllnessCorrelation — red-flag escalation", () => { + it("escalates a sustained low SpO2 run", () => { + const type: MeasurementType = "OXYGEN_SATURATION"; + const baselineDays = jitteredBaseline(98, 1, 21, "2026-01-02"); + const episodeDays: VitalDayPoint[] = [ + { day: "2026-01-10", mean: 91 }, + { day: "2026-01-11", mean: 90 }, + { day: "2026-01-12", mean: 89 }, // 3 consecutive ≤92 + { day: "2026-01-13", mean: 96 }, + { day: "2026-01-14", mean: 98 }, + ]; + const out = computeIllnessCorrelation( + input({ series: [{ type, baselineDays, episodeDays }] }), + ); + expect(out.status).toBe("ok"); + if (out.status !== "ok") return; + expect(out.value.redFlags).toHaveLength(1); + expect(out.value.redFlags[0].reason).toBe("sustained_low_spo2"); + expect(out.value.redFlags[0].worstValue).toBe(89); + expect(out.value.redFlags[0].days).toBe(3); + }); + + it("does not escalate a single low SpO2 day", () => { + const type: MeasurementType = "OXYGEN_SATURATION"; + const baselineDays = jitteredBaseline(98, 1, 21, "2026-01-02"); + const episodeDays: VitalDayPoint[] = [ + { day: "2026-01-10", mean: 91 }, // one dip only + { day: "2026-01-11", mean: 97 }, + { day: "2026-01-12", mean: 98 }, + { day: "2026-01-13", mean: 98 }, + ]; + const out = computeIllnessCorrelation( + input({ series: [{ type, baselineDays, episodeDays }] }), + ); + expect(out.status).toBe("ok"); + if (out.status !== "ok") return; + expect(out.value.redFlags).toHaveLength(0); + }); +}); diff --git a/src/lib/illness/__tests__/retrospective.test.ts b/src/lib/illness/__tests__/retrospective.test.ts new file mode 100644 index 000000000..1c143b71d --- /dev/null +++ b/src/lib/illness/__tests__/retrospective.test.ts @@ -0,0 +1,73 @@ +/** + * v1.18.1 P3 — illness retrospective summary aggregation. + * + * Asserts the count/byMonth/byType tallies, the CHRONIC_ONGOING gap exclusion, + * and the thin-data gate: the "typical recovery gap" is WITHHELD below the + * min-sample floor (never a "typical" claim from one episode). + */ +import { describe, expect, it } from "vitest"; +import { + summarizeIllnessRetrospective, + MIN_EPISODES_FOR_TYPICAL_GAP, + type RetrospectiveEpisode, +} from "../retrospective"; + +function ep(over: Partial): RetrospectiveEpisode { + return { + id: "e", + type: "INFECTION", + onsetDay: "2026-01-10", + resolved: true, + recoveryGapDays: 2, + lifecycle: "ACUTE", + ...over, + }; +} + +describe("summarizeIllnessRetrospective", () => { + it("counts episodes, resolved, by-month, by-type", () => { + const out = summarizeIllnessRetrospective([ + ep({ id: "a", onsetDay: "2026-01-05", type: "INFECTION" }), + ep({ id: "b", onsetDay: "2026-11-20", type: "INFECTION", resolved: false, recoveryGapDays: null }), + ep({ id: "c", onsetDay: "2026-11-02", type: "ALLERGY" }), + ]); + expect(out.episodeCount).toBe(3); + expect(out.resolvedCount).toBe(2); + expect(out.byMonth[1]).toBe(1); + expect(out.byMonth[11]).toBe(2); + expect(out.byType.INFECTION).toBe(2); + expect(out.byType.ALLERGY).toBe(1); + }); + + it("withholds the typical gap below the min-sample floor", () => { + const out = summarizeIllnessRetrospective([ + ep({ id: "a", recoveryGapDays: 3 }), + ep({ id: "b", recoveryGapDays: 5 }), + ]); + expect(MIN_EPISODES_FOR_TYPICAL_GAP).toBe(3); + expect(out.gapSampleSize).toBe(2); + expect(out.typicalRecoveryGapDays).toBeNull(); + }); + + it("reports the median typical gap at/above the floor", () => { + const out = summarizeIllnessRetrospective([ + ep({ id: "a", recoveryGapDays: 2 }), + ep({ id: "b", recoveryGapDays: 4 }), + ep({ id: "c", recoveryGapDays: 6 }), + ]); + expect(out.gapSampleSize).toBe(3); + expect(out.typicalRecoveryGapDays).toBe(4); + }); + + it("excludes CHRONIC_ONGOING and null gaps from the median", () => { + const out = summarizeIllnessRetrospective([ + ep({ id: "a", recoveryGapDays: 2 }), + ep({ id: "b", recoveryGapDays: 4 }), + ep({ id: "c", recoveryGapDays: 6 }), + ep({ id: "d", recoveryGapDays: 100, lifecycle: "CHRONIC_ONGOING" }), + ep({ id: "e", recoveryGapDays: null }), + ]); + expect(out.gapSampleSize).toBe(3); + expect(out.typicalRecoveryGapDays).toBe(4); + }); +}); diff --git a/src/lib/illness/correlation-read.ts b/src/lib/illness/correlation-read.ts new file mode 100644 index 000000000..29667cefb --- /dev/null +++ b/src/lib/illness/correlation-read.ts @@ -0,0 +1,168 @@ +/** + * Illness correlation engine — the READ layer (v1.18.1, Workstream B / P3). + * + * Resolves the per-vital per-day mean series the pure `computeIllnessCorrelation` + * engine needs, then runs the engine. Two windows per vital, and the seam + * between them is the BASELINE-CONTAMINATION GUARD: + * + * baselineDays : [onset − lookback − BASELINE_WINDOW_DAYS, onset − lookback) + * episodeDays : [onset − lookback, end] + * + * The baseline window ENDS strictly before the pre-onset lookback, so no day + * inside (or just before) the episode can poison the baseline the episode is + * measured against. This is the single most important reliability property of + * the engine — a baseline that includes the illness days would shrink every + * deviation toward zero and silently under-report the very anomaly we exist to + * surface. + * + * Reads are DAY-native (the spread invariant forbids composing a band from a + * coarser tier's `sd`): the rollup DAY tier when covered, a bounded per-type + * live-SQL fallback on a coverage miss. The `readDayMeanSeries` helper the + * baseline engine already exports does exactly this and is reused verbatim, so + * the rollup-vs-live parity the baseline tests pin carries over for free. + */ +import type { MeasurementType } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { readDayMeanSeries } from "@/lib/insights/derived/baseline"; +import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage"; +import { dayKeyForUserTz } from "@/lib/measurements/consolidation-tz"; +import { resolveUserTimezone } from "@/lib/measurements/consolidation-base"; +import type { Derived } from "@/lib/insights/derived/types"; +import { + BASELINE_WINDOW_DAYS, + ILLNESS_SCAN_TYPES, + PRE_ONSET_LOOKBACK_DAYS, + computeIllnessCorrelation, + type IllnessCorrelationValue, + type VitalSeries, +} from "./correlation"; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +/** The episode shape the read layer needs (a subset of the Prisma row). */ +export interface EpisodeForCorrelation { + id: string; + onsetAt: Date; + resolvedAt: Date | null; + lifecycle: string; +} + +/** + * Resolve the per-vital series for one episode (contamination-guarded + * baseline window) and run the pure engine. Returns the same gated + * `Derived` the engine returns — `insufficient` flows straight through. + */ +export async function computeEpisodeCorrelation( + userId: string, + episode: EpisodeForCorrelation, + timezone: string | null, + now: Date = new Date(), +): Promise> { + const tz = resolveUserTimezone(timezone); + const onset = episode.onsetAt; + const end = episode.resolvedAt ?? now; + + // Window seams (UTC instants; the day keys are read in the user's tz). + const preOnsetStart = new Date(onset.getTime() - PRE_ONSET_LOOKBACK_DAYS * MS_PER_DAY); + const baselineEnd = preOnsetStart; // baseline ends where the lookback starts + const baselineStart = new Date( + baselineEnd.getTime() - BASELINE_WINDOW_DAYS * MS_PER_DAY, + ); + + const onsetDay = dayKeyForUserTz(onset, tz); + const feltBetterDay = episode.resolvedAt + ? dayKeyForUserTz(episode.resolvedAt, tz) + : null; + + // One coverage probe shared across every vital read (pool-contention + // mitigation — the same pattern the derived route uses). + const coverage = await probeRollupCoverage(userId); + + const series: VitalSeries[] = []; + let anyLive = false; + let anyDay = false; + + // We attempt every scan type. The read helper reuses the baseline engine's + // rollup-first path (live fallback on a coverage miss); an untracked vital + // returns empty points and is dropped below. Each read is a single windowed + // query, so attempting all types is bounded. + for (const type of ILLNESS_SCAN_TYPES) { + const baseline = await readWindowMeans(userId, type, baselineStart, baselineEnd, coverage, now); + const episodeWin = await readWindowMeans(userId, type, preOnsetStart, end, coverage, now); + if (baseline.points.length === 0 && episodeWin.points.length === 0) continue; + if (baseline.source === "live" || episodeWin.source === "live") anyLive = true; + if (baseline.source === "DAY" || episodeWin.source === "DAY") anyDay = true; + series.push({ + type, + baselineDays: baseline.points.map((p) => ({ day: p.day, mean: p.mean })), + episodeDays: episodeWin.points.map((p) => ({ day: p.day, mean: p.mean })), + }); + } + + const source: "DAY" | "live" | "none" = anyDay + ? "DAY" + : anyLive + ? "live" + : "none"; + + return computeIllnessCorrelation({ + episodeId: episode.id, + window: { onsetDay, feltBetterDay, lifecycle: episode.lifecycle }, + series, + source, + now, + }); +} + +/** + * Per-day means for `(userId, type)` over an explicit [from, to) window. + * Reuses the baseline engine's `readDayMeanSeries` (DAY-native rollup with a + * live fallback) for the whole-window read, then trims to the window so the + * baseline and episode windows can share the one cached read path while + * keeping their seams exact. The trim is on the local day key so the + * contamination guard holds in the user's timezone. + */ +async function readWindowMeans( + userId: string, + type: MeasurementType, + from: Date, + to: Date, + coverage: Awaited>, + now: Date, +): Promise<{ points: { day: string; mean: number }[]; source: "DAY" | "live" | "none" }> { + // The baseline helper reads a trailing window ending at `now`. We need an + // arbitrary [from, to) window, so read directly here: DAY rollup first via + // the helper for the trailing case, else a bounded raw read. To keep the + // seam exact we always do the bounded raw per-day grouping for the bounded + // window (a single indexed query), and only use the helper's rollup path + // when `to` is effectively `now` (the common active-episode case). + const toIsNow = Math.abs(to.getTime() - now.getTime()) < MS_PER_DAY; + if (toIsNow) { + const windowDays = Math.max(1, Math.ceil((to.getTime() - from.getTime()) / MS_PER_DAY)); + const resolved = await readDayMeanSeries(userId, type, windowDays, now, coverage); + const fromKey = from.toISOString().slice(0, 10); + return { + points: resolved.points.filter((p) => p.day >= fromKey), + source: resolved.source === "DAY" ? "DAY" : resolved.source === "none" ? "none" : "live", + }; + } + + const rows = await prisma.measurement.findMany({ + where: { userId, type, deletedAt: null, measuredAt: { gte: from, lt: to } }, + orderBy: { measuredAt: "asc" }, + select: { value: true, measuredAt: true }, + }); + if (rows.length === 0) return { points: [], source: "none" }; + const byDay = new Map(); + for (const row of rows) { + const day = row.measuredAt.toISOString().slice(0, 10); + const acc = byDay.get(day) ?? { sum: 0, count: 0 }; + acc.sum += row.value; + acc.count += 1; + byDay.set(day, acc); + } + const points = [...byDay.entries()] + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([day, acc]) => ({ day, mean: acc.sum / acc.count })); + return { points, source: "live" }; +} diff --git a/src/lib/illness/correlation.ts b/src/lib/illness/correlation.ts new file mode 100644 index 000000000..af7ad174d --- /dev/null +++ b/src/lib/illness/correlation.ts @@ -0,0 +1,508 @@ +/** + * Illness correlation + recovery-gap engine (v1.18.1, Workstream B / P3). + * + * Server-authoritative, reliability-first. Given ONE illness episode this + * assembles three retrospective findings — never a prediction, never a + * diagnosis — each gated by the shared coverage/confidence model so a thin + * signal asserts NOTHING: + * + * 1. **Pre-onset anomaly scan** — the N days BEFORE onset, each tracked + * vital's deviation in robust SDs from the user's OWN baseline (median + * ± MAD, the same `computeVitalsBaseline` engine), the baseline computed + * from a window that ENDS before the pre-onset lookback so the episode's + * own physiology can never poison the baseline it is measured against. + * "How did it announce itself?" + * 2. **Nadir / what-dropped** — across the episode's active span, the + * furthest each vital strayed from baseline (the nadir day + magnitude + + * direction). "What dropped." + * 3. **Recovery-gap** — the intellectual core: physiological return-to- + * baseline (first day a previously-deviated vital re-enters its band and + * STAYS in for `RETURN_STABILITY_DAYS`) vs the felt-better marker + * (`resolvedAt`). The gap is `physiologicalReturn − feltBetter` in days: + * positive = the body lagged the feeling (you felt better before your + * numbers did), negative = numbers normalised first. CHRONIC_ONGOING is + * excluded (no recovered date). + * + * The directionality of "worse" is metric-specific (a HRV/recovery DROP and a + * resting-HR/temperature RISE both mean "stressed"); `ADVERSE_DIRECTION` + * pins it per metric. Red-flag patterns (a sustained large oxygen-saturation + * drop, a sustained fever) ESCALATE to a "seek care" flag — never reassure. + * + * Pure given its inputs: the caller does the bounded reads (baseline series + + * per-day vital means within the episode window) and passes them in, so the + * whole engine is unit-testable against golden fixtures with no DB. The route + * wires the reads; the parity test asserts the rollup-fed path and a raw-SQL + * path agree. + */ +import type { MeasurementType } from "@/generated/prisma/client"; +import { buildBaselineBand, median } from "@/lib/insights/derived/baseline"; +import { + buildInsufficient, + buildOk, + deriveCoverage, + nowProvenanceTimestamp, +} from "@/lib/insights/derived/coverage"; +import type { Derived } from "@/lib/insights/derived/types"; + +/* ── tunables (documented invariants) ────────────────────────────────── */ + +/** Days before onset scanned for the "how did it announce itself" signal. */ +export const PRE_ONSET_LOOKBACK_DAYS = 7; +/** Trailing baseline window (days), ending BEFORE the pre-onset lookback. */ +export const BASELINE_WINDOW_DAYS = 30; +/** + * Minimum distinct baseline days a vital needs before we trust its band. + * Mirrors `computeVitalsBaseline`'s 7-day floor — below this the vital is + * dropped from the scan rather than asserted on a fragile band. + */ +export const MIN_BASELINE_DAYS = 7; +/** + * A deviation of ≥ this many robust SDs (k·MAD·scale units) from baseline + * counts as "notable" for the pre-onset + nadir surfaces. ~2σ-equivalent. + */ +export const NOTABLE_SD = 2; +/** + * Consecutive in-band days required to call a vital "returned to baseline" + * for the recovery-gap (one lucky in-band day is not a recovery). + */ +export const RETURN_STABILITY_DAYS = 3; +/** Minimum episode days with ANY vital coverage before the gap is asserted. */ +export const MIN_EPISODE_COVERAGE_DAYS = 4; + +/** + * Per-metric adverse direction — which way a reading moves when the body is + * under illness stress. `up` = a RISE is adverse (resting HR, temperature, + * respiratory rate, BP); `down` = a DROP is adverse (HRV, recovery, SpO2). + * Weight/glucose have no single illness-adverse direction → omitted (their + * deviation magnitude is still surfaced, direction stays neutral). + */ +export const ADVERSE_DIRECTION: Partial> = + { + RESTING_HEART_RATE: "up", + PULSE: "up", + BODY_TEMPERATURE: "up", + SKIN_TEMPERATURE: "up", + RESPIRATORY_RATE: "up", + BLOOD_PRESSURE_SYS: "up", + BLOOD_PRESSURE_DIA: "up", + HEART_RATE_VARIABILITY: "down", + RECOVERY_SCORE: "down", + OXYGEN_SATURATION: "down", + }; + +/** + * Vitals scanned by the illness engine. Superset of the typical-range set + * plus RECOVERY_SCORE (a canonical wellness signal). The caller only passes + * series for the vitals the user actually tracks; the rest are skipped. + */ +export const ILLNESS_SCAN_TYPES: MeasurementType[] = [ + "RESTING_HEART_RATE", + "HEART_RATE_VARIABILITY", + "RECOVERY_SCORE", + "OXYGEN_SATURATION", + "BODY_TEMPERATURE", + "RESPIRATORY_RATE", + "BLOOD_PRESSURE_SYS", + "BLOOD_PRESSURE_DIA", + "PULSE", + "WEIGHT", +]; + +/* ── inputs the caller resolves and passes in (pure engine) ──────────── */ + +/** A per-day mean for one vital (YYYY-MM-DD local day → mean). */ +export interface VitalDayPoint { + /** Local day key, `YYYY-MM-DD`. */ + day: string; + /** Mean reading for the day. */ + mean: number; +} + +/** + * Everything the engine needs for ONE vital: the baseline-window series + * (ENDS before the pre-onset lookback — contamination guard lives in the + * caller's read window) and the episode-window series (pre-onset lookback + * through the felt-better marker, inclusive). + */ +export interface VitalSeries { + type: MeasurementType; + /** Per-day means over the clean baseline window (no episode-span days). */ + baselineDays: VitalDayPoint[]; + /** Per-day means from `onset − lookback` through `resolvedAt` (or today). */ + episodeDays: VitalDayPoint[]; +} + +/** The dates the engine reasons over, all `YYYY-MM-DD` local day keys. */ +export interface EpisodeWindow { + /** Onset day. */ + onsetDay: string; + /** Felt-better marker (resolvedAt local day), or null when still active. */ + feltBetterDay: string | null; + /** Lifecycle — CHRONIC_ONGOING suppresses the recovery-gap. */ + lifecycle: string; +} + +export interface IllnessCorrelationInput { + episodeId: string; + window: EpisodeWindow; + /** One entry per vital the user tracks (others omitted by the caller). */ + series: VitalSeries[]; + /** Provenance source the dominant read resolved against. */ + source: "DAY" | "live" | "none"; + /** Compute time (injected for deterministic tests). */ + now?: Date; +} + +/* ── output value (the server-authoritative DTO payload) ─────────────── */ + +/** One vital's deviation finding on a given day. */ +export interface VitalDeviationFinding { + type: MeasurementType; + /** The day this finding describes (`YYYY-MM-DD`). */ + day: string; + /** The day's mean reading. */ + value: number; + /** Robust baseline center the deviation is measured from. */ + baselineCenter: number; + /** Signed deviation in robust-SD (k·MAD·scale) units; +above / −below. */ + deviationSd: number; + /** Which way the reading moved. */ + direction: "above" | "below"; + /** True when this move is the illness-adverse direction for the metric. */ + adverse: boolean; +} + +/** A vital's recovery timing within the episode. */ +export interface VitalReturnFinding { + type: MeasurementType; + /** First day the vital re-entered its band AND stayed in for the window. */ + returnedDay: string | null; + /** Days from felt-better to physiological return (signed); null if N/A. */ + gapDays: number | null; +} + +export interface IllnessCorrelationValue { + episodeId: string; + /** "How did it announce itself" — notable pre-onset deviations. */ + preOnset: VitalDeviationFinding[]; + /** "What dropped" — each vital's worst (nadir) deviation in the episode. */ + nadir: VitalDeviationFinding[]; + /** Per-vital physiological-return timing. */ + returns: VitalReturnFinding[]; + /** + * The headline recovery-gap in days (median of the per-vital gaps), or + * null when CHRONIC_ONGOING / still active / no vital returned. Positive + * = the body lagged the felt-better marker. + */ + recoveryGapDays: number | null; + /** Felt-better marker echoed for the surface. */ + feltBetterDay: string | null; + /** + * Red-flag escalation — a sustained adverse SpO2 drop or sustained fever + * during the episode. Retrospective, but the copy must escalate ("if this + * recurs, seek care"), NEVER reassure. Empty when nothing tripped. + */ + redFlags: IllnessRedFlag[]; +} + +export interface IllnessRedFlag { + type: MeasurementType; + /** A short stable reason key the i18n layer renders (not free text). */ + reason: "sustained_low_spo2" | "sustained_fever"; + /** The worst observed value during the flagged run. */ + worstValue: number; + /** Consecutive days the adverse threshold held. */ + days: number; +} + +/* ── red-flag thresholds (clinical floors, conservative) ─────────────── */ + +/** SpO2 at/below this for ≥ RED_FLAG_RUN_DAYS escalates. */ +const SPO2_RED_FLAG = 92; +/** Body temperature at/above this (°C) for ≥ RED_FLAG_RUN_DAYS escalates. */ +const FEVER_RED_FLAG = 38.5; +/** Consecutive days an adverse clinical threshold must hold to escalate. */ +const RED_FLAG_RUN_DAYS = 3; + +/* ── pure helpers ─────────────────────────────────────────────────────── */ + +/** Robust band for a vital, or null below the min-baseline-days floor. */ +function bandFor(series: VitalSeries): { + center: number; + spread: number; +} | null { + if (series.baselineDays.length < MIN_BASELINE_DAYS) return null; + const band = buildBaselineBand(series.baselineDays.map((p) => p.mean)); + if (!band || band.spread <= 0) return null; + return { center: band.center, spread: band.spread }; +} + +/** Signed deviation of a value in robust-SD units (spread = k·MAD·scale). */ +function deviationSd(value: number, center: number, spread: number): number { + return (value - center) / spread; +} + +/** Build a deviation finding for one day/value against a band. */ +function finding( + type: MeasurementType, + day: string, + value: number, + center: number, + spread: number, +): VitalDeviationFinding { + const sd = deviationSd(value, center, spread); + const direction: "above" | "below" = sd >= 0 ? "above" : "below"; + const adverseDir = ADVERSE_DIRECTION[type]; + const adverse = + (adverseDir === "up" && direction === "above") || + (adverseDir === "down" && direction === "below"); + return { + type, + day, + value, + baselineCenter: center, + deviationSd: Math.round(sd * 100) / 100, + direction, + adverse, + }; +} + +/** Order day keys chronologically. */ +function byDay(a: { day: string }, b: { day: string }): number { + return a.day < b.day ? -1 : a.day > b.day ? 1 : 0; +} + +/* ── the engine ───────────────────────────────────────────────────────── */ + +/** + * Compute the retrospective correlation findings for one episode. Pure given + * its inputs — the caller resolves the reads (rollup tier with a live-SQL + * fallback) and passes the series in. Returns a gated `Derived`: below the + * coverage floor it returns `insufficient` (never a fabricated finding). + */ +export function computeIllnessCorrelation( + input: IllnessCorrelationInput, +): Derived { + const now = input.now ?? new Date(); + const computedAt = nowProvenanceTimestamp(now); + const { window } = input; + + // Only vitals with a trustworthy own-baseline band participate. + const banded = input.series + .map((s) => ({ series: s, band: bandFor(s) })) + .filter((b): b is { series: VitalSeries; band: { center: number; spread: number } } => b.band !== null); + + const inputs = banded.map((b) => String(b.series.type)); + const provenance = { + inputs, + source: input.source, + windowDays: BASELINE_WINDOW_DAYS, + computedAt, + }; + + // Coverage: how many distinct episode days carry ANY banded vital reading. + const episodeDayKeys = new Set(); + for (const b of banded) { + for (const p of b.series.episodeDays) { + if (p.day >= window.onsetDay) episodeDayKeys.add(p.day); + } + } + const coverageDays = episodeDayKeys.size; + + if (banded.length === 0 || coverageDays < MIN_EPISODE_COVERAGE_DAYS) { + const { coverage } = deriveCoverage({ + requiredInputs: 1, + presentInputs: banded.length, + historyDays: coverageDays, + missing: banded.length === 0 ? ILLNESS_SCAN_TYPES.map(String) : [], + fullHistoryDays: MIN_EPISODE_COVERAGE_DAYS, + }); + return buildInsufficient({ + coverage, + provenance, + reason: + banded.length === 0 + ? "no_baselined_vitals" + : "insufficient_episode_coverage", + }); + } + + const preOnset: VitalDeviationFinding[] = []; + const nadir: VitalDeviationFinding[] = []; + const returns: VitalReturnFinding[] = []; + const redFlags: IllnessRedFlag[] = []; + + for (const { series, band } of banded) { + const { type } = series; + const { center, spread } = band; + const episode = [...series.episodeDays].sort(byDay); + + // 1) Pre-onset scan: lookback window strictly before onset. + const pre = episode.filter((p) => p.day < window.onsetDay); + let worstPre: VitalDeviationFinding | null = null; + for (const p of pre) { + const f = finding(type, p.day, p.mean, center, spread); + if ( + Math.abs(f.deviationSd) >= NOTABLE_SD && + (worstPre === null || + Math.abs(f.deviationSd) > Math.abs(worstPre.deviationSd)) + ) { + worstPre = f; + } + } + if (worstPre) preOnset.push(worstPre); + + // 2) Nadir: worst deviation across the active span (onset → end). + const active = episode.filter((p) => p.day >= window.onsetDay); + let worstActive: VitalDeviationFinding | null = null; + for (const p of active) { + const f = finding(type, p.day, p.mean, center, spread); + if ( + Math.abs(f.deviationSd) >= NOTABLE_SD && + (worstActive === null || + Math.abs(f.deviationSd) > Math.abs(worstActive.deviationSd)) + ) { + worstActive = f; + } + } + if (worstActive) nadir.push(worstActive); + + // 3) Physiological return: first active day this vital deviated + // notably-adverse, then later re-entered the band and STAYED in for + // RETURN_STABILITY_DAYS. Only meaningful if it actually deviated. + const deviatedActive = active.some((p) => { + const f = finding(type, p.day, p.mean, center, spread); + return Math.abs(f.deviationSd) >= NOTABLE_SD; + }); + if (deviatedActive && window.lifecycle !== "CHRONIC_ONGOING") { + const returnedDay = firstStableReturn(active, center, spread); + const gapDays = + returnedDay && window.feltBetterDay + ? dayDiff(window.feltBetterDay, returnedDay) + : null; + returns.push({ type, returnedDay, gapDays }); + } + + // Red-flag escalation (clinical floors, retrospective). + const flag = detectRedFlag(type, active); + if (flag) redFlags.push(flag); + } + + // Headline gap = median of the per-vital signed gaps (robust to one vital + // recovering oddly). Null for CHRONIC_ONGOING / still active / no returns. + const gaps = returns + .map((r) => r.gapDays) + .filter((g): g is number => g !== null); + const recoveryGapDays = + window.lifecycle === "CHRONIC_ONGOING" || gaps.length === 0 + ? null + : Math.round(median(gaps)); + + const { coverage, confidence } = deriveCoverage({ + requiredInputs: 1, + presentInputs: banded.length, + historyDays: coverageDays, + missing: [], + fullHistoryDays: Math.max(MIN_EPISODE_COVERAGE_DAYS, 14), + }); + + return buildOk({ + value: { + episodeId: input.episodeId, + preOnset: preOnset.sort((a, b) => Math.abs(b.deviationSd) - Math.abs(a.deviationSd)), + nadir: nadir.sort((a, b) => Math.abs(b.deviationSd) - Math.abs(a.deviationSd)), + returns, + recoveryGapDays, + feltBetterDay: window.feltBetterDay, + redFlags, + }, + coverage, + confidence, + provenance, + }); +} + +/** + * First day in `active` (chronological) where the vital is in-band AND stays + * in-band for the next `RETURN_STABILITY_DAYS` observed days. Null when it + * never settles. "In-band" = |deviation| < NOTABLE_SD. + */ +function firstStableReturn( + active: VitalDayPoint[], + center: number, + spread: number, +): string | null { + const inBand = active.map((p) => ({ + day: p.day, + in: Math.abs(deviationSd(p.mean, center, spread)) < NOTABLE_SD, + })); + for (let i = 0; i < inBand.length; i++) { + if (!inBand[i].in) continue; + let run = 0; + for (let j = i; j < inBand.length && inBand[j].in; j++) run++; + if (run >= RETURN_STABILITY_DAYS) return inBand[i].day; + } + return null; +} + +/** + * Detect a retrospective red flag: a sustained adverse SpO2 drop or a + * sustained fever during the active span. Conservative clinical floors; + * escalation copy lives in the i18n layer. + */ +function detectRedFlag( + type: MeasurementType, + active: VitalDayPoint[], +): IllnessRedFlag | null { + const sorted = [...active].sort(byDay); + if (type === "OXYGEN_SATURATION") { + return runFlag(sorted, (v) => v <= SPO2_RED_FLAG, "sustained_low_spo2", type, "min"); + } + if (type === "BODY_TEMPERATURE") { + return runFlag(sorted, (v) => v >= FEVER_RED_FLAG, "sustained_fever", type, "max"); + } + return null; +} + +/** Longest consecutive run matching `predicate`; flag when ≥ RED_FLAG_RUN_DAYS. */ +function runFlag( + sorted: VitalDayPoint[], + 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; + for (const p of sorted) { + if (predicate(p.mean)) { + run++; + runWorst = + runWorst === null + ? p.mean + : worst === "min" + ? Math.min(runWorst, p.mean) + : Math.max(runWorst, p.mean); + if (run > bestRun) { + bestRun = run; + bestWorst = runWorst; + } + } else { + run = 0; + runWorst = null; + } + } + if (bestRun >= RED_FLAG_RUN_DAYS && bestWorst !== null) { + return { type, reason, worstValue: bestWorst, days: bestRun }; + } + return null; +} + +/** Whole-day signed difference `to − from` for two `YYYY-MM-DD` keys. */ +export function dayDiff(from: string, to: string): number { + const a = Date.parse(`${from}T00:00:00Z`); + const b = Date.parse(`${to}T00:00:00Z`); + return Math.round((b - a) / (24 * 60 * 60 * 1000)); +} diff --git a/src/lib/illness/retrospective.ts b/src/lib/illness/retrospective.ts new file mode 100644 index 000000000..08ab725f3 --- /dev/null +++ b/src/lib/illness/retrospective.ts @@ -0,0 +1,98 @@ +/** + * Illness retrospective summary (v1.18.1, Workstream B / P3). + * + * The look-back surface: "sick N times this year · typical recovery gap X + * days". RETROSPECTIVE ONLY — it summarises what already happened. It is + * NEVER a predictor or diagnoser; the recurrence figure is a count of past + * episodes, not a forecast, and the copy layer must frame it as such. + * + * Pure given a list of resolved episode summaries + their computed gaps. The + * caller resolves the per-episode recovery-gap (via the correlation engine, + * which is itself coverage-gated) and passes the numbers in; this layer only + * aggregates. The aggregate is itself gated — with too few resolved episodes + * to say anything honest, it reports the count but withholds the "typical + * gap" (asserts nothing thin). + */ +import { median } from "@/lib/insights/derived/baseline"; + +/** Minimum resolved-with-gap episodes before a "typical gap" is asserted. */ +export const MIN_EPISODES_FOR_TYPICAL_GAP = 3; + +/** One episode's contribution to the summary. */ +export interface RetrospectiveEpisode { + id: string; + type: string; + /** Onset local day `YYYY-MM-DD` (for the recurrence-month tally). */ + onsetDay: string; + /** Whether the episode is resolved (has a felt-better marker). */ + resolved: boolean; + /** The computed recovery-gap in days, or null when ungated/unavailable. */ + recoveryGapDays: number | null; + /** Lifecycle — CHRONIC_ONGOING never contributes a gap. */ + lifecycle: string; +} + +export interface IllnessRetrospectiveSummary { + /** Episodes whose onset falls in the requested window (e.g. trailing year). */ + episodeCount: number; + /** Of those, how many are resolved. */ + resolvedCount: number; + /** + * Typical (median) recovery-gap in days across episodes with a computed + * gap — null when fewer than `MIN_EPISODES_FOR_TYPICAL_GAP` qualify. A + * positive gap means the body typically lagged the felt-better marker. + */ + typicalRecoveryGapDays: number | null; + /** How many episodes backed the typical-gap figure (transparency). */ + gapSampleSize: number; + /** + * Recurrence tally by calendar month (1–12) → count, retrospective only. + * Drives "you tend to log illnesses in November" as an OBSERVATION, never + * a prediction. Empty when nothing falls in the window. + */ + byMonth: Record; + /** Per-type episode counts in the window (retrospective breakdown). */ + byType: Record; +} + +/** + * Aggregate the per-episode figures into the retrospective summary. Pure. The + * typical-gap is withheld (null) below the min-sample floor so a single + * episode never becomes a "typical" claim. + */ +export function summarizeIllnessRetrospective( + episodes: RetrospectiveEpisode[], +): IllnessRetrospectiveSummary { + const byMonth: Record = {}; + const byType: Record = {}; + let resolvedCount = 0; + const gaps: number[] = []; + + for (const e of episodes) { + const month = Number(e.onsetDay.slice(5, 7)); + if (month >= 1 && month <= 12) byMonth[month] = (byMonth[month] ?? 0) + 1; + byType[e.type] = (byType[e.type] ?? 0) + 1; + if (e.resolved) resolvedCount++; + if ( + e.lifecycle !== "CHRONIC_ONGOING" && + e.recoveryGapDays !== null && + Number.isFinite(e.recoveryGapDays) + ) { + gaps.push(e.recoveryGapDays); + } + } + + const typicalRecoveryGapDays = + gaps.length >= MIN_EPISODES_FOR_TYPICAL_GAP + ? Math.round(median(gaps)) + : null; + + return { + episodeCount: episodes.length, + resolvedCount, + typicalRecoveryGapDays, + gapSampleSize: gaps.length, + byMonth, + byType, + }; +} diff --git a/src/lib/query-keys/illness.ts b/src/lib/query-keys/illness.ts index 9c2276915..1b1d9ee80 100644 --- a/src/lib/query-keys/illness.ts +++ b/src/lib/query-keys/illness.ts @@ -18,4 +18,17 @@ export const illnessKeys = { illnessEpisode: (id: string) => ["illness", "episode", id] as const, illnessDayLog: (episodeId: string, date: string) => ["illness", "day-log", episodeId, date] as const, + /** + * v1.18.1 P3 — the per-episode retrospective correlation findings + * (pre-onset scan, nadir, recovery-gap). Server-authoritative; the read + * is gated, so the surface pattern-matches `status` rather than recomputing. + */ + illnessCorrelation: (id: string) => + ["illness", "correlation", id] as const, + /** + * v1.18.1 P3 — the cross-episode retrospective summary ("sick N times · + * typical recovery gap X days") over a trailing window in days. + */ + illnessInsights: (windowDays: number) => + ["illness", "insights", windowDays] as const, }; diff --git a/src/lib/validations/illness.ts b/src/lib/validations/illness.ts index b97de9018..5af3fc615 100644 --- a/src/lib/validations/illness.ts +++ b/src/lib/validations/illness.ts @@ -133,3 +133,14 @@ export type IllnessDayLogInput = z.infer; /** The single-day read query: `GET .../day-logs?date=YYYY-MM-DD`. */ export const illnessDayLogQuerySchema = z.object({ date: dateString }); + +/* ── P3 retrospective insight window ─────────────────────────────────── */ + +/** + * The retrospective-insight window query: `GET /api/illness/insights? + * windowDays=365`. Bounded 30..1095 days; defaults to a trailing year. + * Retrospective only — the engine summarises past episodes, never forecasts. + */ +export const illnessInsightsQuerySchema = z.object({ + windowDays: z.coerce.number().int().min(30).max(1095).optional(), +});