From 82cabff8d0a4adabfa36f0d2258d54fe27f060b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 16 Jun 2026 23:17:48 +0200 Subject: [PATCH 1/4] fix(illness): correct the recovery-gap and red-flag engine Anchor the physiological-return search after the first notable deviation so an early in-band run-up can no longer be reported as a return before the vital ever deviated (the spurious-negative-gap bug). Decouple the red-flag escalation from the baseline-band loop: it now scans the raw episode days for SpO2 and temperature against absolute clinical floors, so a rock-steady (MAD=0) vital still escalates a low SpO2 or sustained fever. Union the day-log feverC series into the fever flag and prefer per-day max so an evening spike is not masked by the daily mean. Key every per-day series in the user's timezone (rollup buckets and the raw fallback) so the vital series lines up with the tz-keyed onset and felt-better markers instead of an off-by-one for non-UTC accounts. --- .../__tests__/correlation-read.test.ts | 65 +++++++ src/lib/illness/__tests__/correlation.test.ts | 139 +++++++++++++++ src/lib/illness/correlation-read.ts | 137 +++++++++++---- src/lib/illness/correlation.ts | 159 +++++++++++++----- 4 files changed, 425 insertions(+), 75 deletions(-) diff --git a/src/lib/illness/__tests__/correlation-read.test.ts b/src/lib/illness/__tests__/correlation-read.test.ts index 01c1625a5..5ba0da5b7 100644 --- a/src/lib/illness/__tests__/correlation-read.test.ts +++ b/src/lib/illness/__tests__/correlation-read.test.ts @@ -51,6 +51,7 @@ const NOW = new Date("2026-01-21T12:00:00Z"); const db = vi.hoisted(() => ({ measurement: { findMany: vi.fn() }, + illnessDayLog: { findMany: vi.fn() }, })); const rollup = vi.hoisted(() => ({ probeRollupCoverage: vi.fn(), @@ -93,6 +94,8 @@ function rawRowsWithin(from: Date, to: Date) { beforeEach(() => { vi.clearAllMocks(); + // No day-log fever rows in the parity/contamination fixtures. + db.illnessDayLog.findMany.mockResolvedValue([]); }); afterEach(() => { vi.restoreAllMocks(); @@ -141,6 +144,7 @@ describe("computeEpisodeCorrelation — rollup-vs-live parity", () => { // baseline engine's live fallback groups raw rows itself for the // trailing read; we serve the same DAILY universe for any window. ── vi.clearAllMocks(); + db.illnessDayLog.findMany.mockResolvedValue([]); const noCoverage = new Map( ILLNESS_SCAN_TYPES.map((t) => [String(t), false]), ); @@ -181,6 +185,67 @@ describe("computeEpisodeCorrelation — rollup-vs-live parity", () => { }); }); +describe("computeEpisodeCorrelation — user-tz day keying", () => { + it("keys a 03:00Z reading to the user's LOCAL day (negative-offset tz)", async () => { + // America/Los_Angeles is UTC−8 (−7 DST). A 2026-01-12T03:00Z reading is + // 2026-01-11 19:00 local → local day 2026-01-11, NOT the UTC 2026-01-12. + // The vital series must key by the user's day so it lines up with the + // tz-keyed onset/feltBetter markers (the off-by-one this fix closes). + rollup.probeRollupCoverage.mockResolvedValue( + new Map(ILLNESS_SCAN_TYPES.map((t) => [String(t), false])), + ); + rollup.readBestGranularityRollups.mockResolvedValue(null); + + // A simple RHR universe with a notable spike whose UTC timestamp crosses + // the local midnight. Baseline window is flat-ish at 55. + const rows: Array<{ value: number; measuredAt: Date }> = []; + // 20 baseline days at 55 ± small jitter, ending well before the episode. + let cursor = Date.parse("2025-12-20T20:00:00Z"); + const jit = [-2, -1, 0, 1, 2]; + for (let i = 0; i < 20; i++) { + rows.push({ value: 55 + jit[i % jit.length], measuredAt: new Date(cursor) }); + cursor += 24 * 60 * 60 * 1000; + } + // Episode-span readings, including the boundary-crossing 03:00Z spike. + rows.push({ value: 56, measuredAt: new Date("2026-01-10T20:00:00Z") }); + rows.push({ value: 76, measuredAt: new Date("2026-01-12T03:00:00Z") }); // local 01-11 + rows.push({ value: 74, measuredAt: new Date("2026-01-13T20:00:00Z") }); + rows.push({ value: 70, measuredAt: new Date("2026-01-15T20:00:00Z") }); + rows.push({ value: 60, measuredAt: new Date("2026-01-16T20:00:00Z") }); + rows.push({ value: 58, measuredAt: new Date("2026-01-17T20:00:00Z") }); + + 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.getTime(); + const to = where.measuredAt.lt?.getTime() ?? NOW.getTime(); + return rows.filter( + (r) => r.measuredAt.getTime() >= from && r.measuredAt.getTime() < to, + ); + }, + ); + + const episode = { + id: "ep-tz", + onsetAt: new Date("2026-01-11T08:00:00Z"), // local 01-11 00:00 LA + resolvedAt: null as Date | null, + lifecycle: "ACUTE", + }; + + const out = await computeEpisodeCorrelation( + "u1", + episode, + "America/Los_Angeles", + NOW, + ); + expect(out.status).toBe("ok"); + if (out.status !== "ok") return; + // The 76 spike lands on the user-local day 2026-01-11, not the UTC 01-12. + expect(out.value.nadir[0]?.value).toBe(76); + expect(out.value.nadir[0]?.day).toBe("2026-01-11"); + }); +}); + describe("computeEpisodeCorrelation — contamination-guard read window", () => { it("never queries baseline rows on or after the pre-onset lookback start", async () => { rollup.probeRollupCoverage.mockResolvedValue( diff --git a/src/lib/illness/__tests__/correlation.test.ts b/src/lib/illness/__tests__/correlation.test.ts index 5c627d887..be0b29720 100644 --- a/src/lib/illness/__tests__/correlation.test.ts +++ b/src/lib/illness/__tests__/correlation.test.ts @@ -291,4 +291,143 @@ describe("computeIllnessCorrelation — red-flag escalation", () => { if (out.status !== "ok") return; expect(out.value.redFlags).toHaveLength(0); }); + + it("escalates a MAD=0 (rock-steady) SpO2 that drops — decoupled from the band", () => { + // A perfectly steady 98 baseline → MAD=0 → the band is DROPPED, so this + // vital never participates in the banded loop. A 90% SpO2 run must STILL + // escalate (the safety-critical case the band filter previously hid). To + // satisfy the episode-coverage floor we add a separate banded RHR vital. + const spo2: MeasurementType = "OXYGEN_SATURATION"; + const rhr: MeasurementType = "RESTING_HEART_RATE"; + const out = computeIllnessCorrelation( + input({ + series: [ + { + type: spo2, + baselineDays: flatBaseline(98, 21, "2026-01-02"), // MAD = 0 → no band + episodeDays: [ + { day: "2026-01-10", mean: 90 }, + { day: "2026-01-11", mean: 89 }, + { day: "2026-01-12", mean: 90 }, // 3 days ≤ 92 + { day: "2026-01-13", mean: 97 }, + ], + }, + { + type: rhr, + baselineDays: jitteredBaseline(55, 1, 21, "2026-01-02"), + episodeDays: flatBaseline(55, 5, "2026-01-15"), + }, + ], + }), + ); + expect(out.status).toBe("ok"); + if (out.status !== "ok") return; + const spo2Flag = out.value.redFlags.find( + (f) => f.reason === "sustained_low_spo2", + ); + expect(spo2Flag).toBeDefined(); + expect(spo2Flag?.worstValue).toBe(89); + expect(spo2Flag?.days).toBe(3); + }); + + it("escalates a sustained fever logged via the day-log feverC (no temp series)", () => { + // No BODY_TEMPERATURE vital at all — the canonical journaling-fever path. + // A banded RHR vital satisfies the coverage floor; the fever escalates + // purely from the day-log feverC union. + const rhr: MeasurementType = "RESTING_HEART_RATE"; + const out = computeIllnessCorrelation( + input({ + series: [ + { + type: rhr, + baselineDays: jitteredBaseline(55, 1, 21, "2026-01-02"), + episodeDays: flatBaseline(55, 5, "2026-01-15"), + }, + ], + dayLogFever: [ + { day: "2026-01-10", feverC: 39.2 }, + { day: "2026-01-11", feverC: 38.9 }, + { day: "2026-01-12", feverC: 38.6 }, // 3 days ≥ 38.5 + { day: "2026-01-13", feverC: 37.4 }, + ], + }), + ); + expect(out.status).toBe("ok"); + if (out.status !== "ok") return; + const fever = out.value.redFlags.find((f) => f.reason === "sustained_fever"); + expect(fever).toBeDefined(); + expect(fever?.days).toBe(3); + expect(fever?.worstValue).toBe(39.2); + }); + + it("uses per-day MAX for fever so an evening spike is not masked by the mean", () => { + // The mean episode series stays sub-fever; the per-day MAX crosses 38.5. + const temp: MeasurementType = "BODY_TEMPERATURE"; + const rhr: MeasurementType = "RESTING_HEART_RATE"; + const out = computeIllnessCorrelation( + input({ + series: [ + { + type: temp, + baselineDays: jitteredBaseline(36.6, 0.2, 21, "2026-01-02"), + episodeDays: [ + { day: "2026-01-10", mean: 37.4 }, + { day: "2026-01-11", mean: 37.5 }, + { day: "2026-01-12", mean: 37.6 }, + ], + episodeDayMax: [ + { day: "2026-01-10", mean: 38.8 }, + { day: "2026-01-11", mean: 38.7 }, + { day: "2026-01-12", mean: 38.9 }, // 3 days max ≥ 38.5 + ], + }, + { + type: rhr, + baselineDays: jitteredBaseline(55, 1, 21, "2026-01-02"), + episodeDays: flatBaseline(55, 5, "2026-01-15"), + }, + ], + }), + ); + expect(out.status).toBe("ok"); + if (out.status !== "ok") return; + const fever = out.value.redFlags.find((f) => f.reason === "sustained_fever"); + expect(fever).toBeDefined(); + expect(fever?.days).toBe(3); + }); +}); + +describe("computeIllnessCorrelation — return anchor", () => { + it("never reports a return BEFORE the first deviation (no spurious gap)", () => { + // The vital is IN BAND for the first 4 active days (the run-up), then + // spikes, then settles. A naive scan from i=0 would call the early in-band + // run the 'return' and yield a negative gap. The anchored search must pick + // the post-deviation settle (01-19), giving a positive gap. + const type: MeasurementType = "RESTING_HEART_RATE"; + const baselineDays = jitteredBaseline(55, 1, 21, "2026-01-02"); + const episodeDays: VitalDayPoint[] = [ + { day: "2026-01-10", mean: 55 }, // in band (run-up) + { day: "2026-01-11", mean: 55 }, + { day: "2026-01-12", mean: 55 }, + { day: "2026-01-13", mean: 75 }, // FIRST deviation + { day: "2026-01-14", mean: 74 }, + { day: "2026-01-15", mean: 70 }, + { day: "2026-01-19", mean: 55 }, // settle start + { day: "2026-01-20", mean: 55 }, + { day: "2026-01-21", mean: 55 }, // stable 3 days + ]; + const out = computeIllnessCorrelation( + input({ + window: { onsetDay: "2026-01-10", feltBetterDay: "2026-01-15", lifecycle: "ACUTE" }, + series: [{ type, baselineDays, episodeDays }], + }), + ); + expect(out.status).toBe("ok"); + if (out.status !== "ok") return; + // The return is the post-deviation settle, NOT the 01-10 run-up. + expect(out.value.returns[0]?.returnedDay).toBe("2026-01-19"); + // Gap = 01-19 − 01-15 = +4 (positive — body lagged), never negative. + expect(out.value.recoveryGapDays).toBe(4); + expect(out.value.recoveryGapDays).toBeGreaterThan(0); + }); }); diff --git a/src/lib/illness/correlation-read.ts b/src/lib/illness/correlation-read.ts index 29667cefb..d37c42240 100644 --- a/src/lib/illness/correlation-read.ts +++ b/src/lib/illness/correlation-read.ts @@ -17,13 +17,16 @@ * * 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. + * live-SQL fallback on a coverage miss. EVERY per-day key is the USER'S local + * day (`dayKeyForUserTz`), never a raw UTC slice — the onset/feltBetter markers + * are already keyed in the user's tz, so the vital series must match or a + * non-UTC user gets an off-by-one (a 03:00Z reading is local D−1 but UTC D). + * Rollup DAY buckets are minted at the UTC midnight of the user-tz day, so + * keying their `bucketStart` through `dayKeyForUserTz` is exact. */ import type { MeasurementType } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; -import { readDayMeanSeries } from "@/lib/insights/derived/baseline"; +import { readBestGranularityRollups } from "@/lib/rollups/measurement-read-wmy"; import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage"; import { dayKeyForUserTz } from "@/lib/measurements/consolidation-tz"; import { resolveUserTimezone } from "@/lib/measurements/consolidation-base"; @@ -33,6 +36,7 @@ import { ILLNESS_SCAN_TYPES, PRE_ONSET_LOOKBACK_DAYS, computeIllnessCorrelation, + type DayLogFeverPoint, type IllnessCorrelationValue, type VitalSeries, } from "./correlation"; @@ -47,6 +51,12 @@ export interface EpisodeForCorrelation { lifecycle: string; } +/** A windowed per-day read: mean + max per local day, with provenance. */ +interface WindowRead { + points: { day: string; mean: number; max: number }[]; + source: "DAY" | "live" | "none"; +} + /** * Resolve the per-vital series for one episode (contamination-guarded * baseline window) and run the pure engine. Returns the same gated @@ -82,13 +92,12 @@ export async function computeEpisodeCorrelation( 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. + // We attempt every scan type. 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); + const baseline = await readWindowMeans(userId, type, baselineStart, baselineEnd, coverage, tz, now); + const episodeWin = await readWindowMeans(userId, type, preOnsetStart, end, coverage, tz, 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; @@ -96,9 +105,17 @@ export async function computeEpisodeCorrelation( type, baselineDays: baseline.points.map((p) => ({ day: p.day, mean: p.mean })), episodeDays: episodeWin.points.map((p) => ({ day: p.day, mean: p.mean })), + // Per-day max over the episode window — the fever red-flag prefers it so + // an evening spike averaged toward normal in the daily mean is not masked. + episodeDayMax: episodeWin.points.map((p) => ({ day: p.day, mean: p.max })), }); } + // Union the canonical journaling-fever path: the illness day-log `feverC` + // series over the episode window. A user logging 39.2 °C for days with no + // HealthKit thermometer must still escalate. Per-day MAX. + const dayLogFever = await readDayLogFever(episode.id, preOnsetStart, end, tz); + const source: "DAY" | "live" | "none" = anyDay ? "DAY" : anyLive @@ -109,18 +126,18 @@ export async function computeEpisodeCorrelation( episodeId: episode.id, window: { onsetDay, feltBetterDay, lifecycle: episode.lifecycle }, series, + dayLogFever, 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. + * Per-day mean + max for `(userId, type)` over an explicit [from, to) window, + * keyed by the USER'S local day. The DAY rollup serves the trailing + * (`to ≈ now`) case; otherwise a bounded raw read groups per local day. The + * tz-keying is what keeps the contamination-guard seam and the onset/felt- + * better markers in the same calendar for a non-UTC user. */ async function readWindowMeans( userId: string, @@ -128,41 +145,89 @@ async function readWindowMeans( from: Date, to: Date, coverage: Awaited>, + tz: string, 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). +): Promise { 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", - }; + + if (toIsNow && coverage.get(type) === true) { + const windowDays = Math.max( + 1, + Math.ceil((to.getTime() - from.getTime()) / MS_PER_DAY), + ); + const resolved = await readBestGranularityRollups(userId, type, windowDays); + if (resolved && resolved.granularity === "DAY" && resolved.rows.length > 0) { + const fromKey = dayKeyForUserTz(from, tz); + // DAY buckets are minted at the UTC midnight of the user-tz day, so + // keying `bucketStart` through the user tz is exact. + const points = resolved.rows + .map((row) => ({ + day: dayKeyForUserTz(row.bucketStart, tz), + mean: row.mean, + max: row.maxValue ?? row.mean, + })) + .filter((p) => p.day >= fromKey) + .sort((a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : 0)); + if (points.length > 0) return { points, source: "DAY" }; + } + // Coverage said "has buckets" but the window resolved coarser / empty → + // fall through to the live read so the series stays DAY-native. } + // Bounded raw fallback — group per the USER'S local day (never a UTC slice). + // A trailing window (`to ≈ now`) reads open-ended (gte only) so it stays a + // "trailing" read; a bounded window pins `lt` so it can never reach past its + // seam (the contamination guard for the baseline window). + const measuredAt = toIsNow ? { gte: from } : { gte: from, lt: to }; const rows = await prisma.measurement.findMany({ - where: { userId, type, deletedAt: null, measuredAt: { gte: from, lt: to } }, + where: { userId, type, deletedAt: null, measuredAt }, orderBy: { measuredAt: "asc" }, select: { value: true, measuredAt: true }, }); if (rows.length === 0) return { points: [], source: "none" }; - const byDay = new Map(); + 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 }; + const day = dayKeyForUserTz(row.measuredAt, tz); + const acc = byDay.get(day) ?? { sum: 0, count: 0, max: -Infinity }; acc.sum += row.value; acc.count += 1; + acc.max = Math.max(acc.max, row.value); 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 })); + .map(([day, acc]) => ({ day, mean: acc.sum / acc.count, max: acc.max })); return { points, source: "live" }; } + +/** + * Read the illness day-log `feverC` series over [from, to) as per-day MAX + * fever points (keyed by the stored `date`, which is already the user-tz day + * string the day-log was logged under). Drops null fevers. + */ +async function readDayLogFever( + episodeId: string, + from: Date, + to: Date, + tz: string, +): Promise { + const fromKey = dayKeyForUserTz(from, tz); + const toKey = dayKeyForUserTz(to, tz); + const rows = await prisma.illnessDayLog.findMany({ + where: { + episodeId, + deletedAt: null, + feverC: { not: null }, + date: { gte: fromKey, lte: toKey }, + }, + select: { date: true, feverC: true }, + }); + const byDay = new Map(); + for (const row of rows) { + if (row.feverC == null) continue; + byDay.set(row.date, Math.max(byDay.get(row.date) ?? -Infinity, row.feverC)); + } + return [...byDay.entries()] + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([day, feverC]) => ({ day, feverC })); +} diff --git a/src/lib/illness/correlation.ts b/src/lib/illness/correlation.ts index af7ad174d..3a8518f11 100644 --- a/src/lib/illness/correlation.ts +++ b/src/lib/illness/correlation.ts @@ -130,6 +130,26 @@ export interface VitalSeries { baselineDays: VitalDayPoint[]; /** Per-day means from `onset − lookback` through `resolvedAt` (or today). */ episodeDays: VitalDayPoint[]; + /** + * Per-day MAX readings over the episode window, used by the fever red-flag + * so an evening spike averaged toward normal in `episodeDays` (which carries + * means) is not masked. Optional — when omitted the flag falls back to the + * mean series. SpO2 stays mean/min-based (its `worst` is `min`). + */ + episodeDayMax?: VitalDayPoint[]; +} + +/** + * A user-logged fever reading from the illness day-log (`feverC`), keyed by + * the same local day key as the vital series. The fever red-flag unions this + * with any passive BODY_TEMPERATURE series so a user logging 39.2 °C for days + * with no HealthKit thermometer still escalates. Per-day MAX semantics. + */ +export interface DayLogFeverPoint { + /** Local day key, `YYYY-MM-DD`. */ + day: string; + /** Logged fever in °C (the day's max when several were logged). */ + feverC: number; } /** The dates the engine reasons over, all `YYYY-MM-DD` local day keys. */ @@ -147,6 +167,12 @@ export interface IllnessCorrelationInput { window: EpisodeWindow; /** One entry per vital the user tracks (others omitted by the caller). */ series: VitalSeries[]; + /** + * Per-day fever readings from the illness day-log (`feverC`). Unioned into + * the fever red-flag so the canonical journaling-fever path is visible to + * escalation even with no passive BODY_TEMPERATURE rows. Optional. + */ + dayLogFever?: DayLogFeverPoint[]; /** Provenance source the dominant read resolved against. */ source: "DAY" | "live" | "none"; /** Compute time (injected for deterministic tests). */ @@ -330,7 +356,6 @@ export function computeIllnessCorrelation( const preOnset: VitalDeviationFinding[] = []; const nadir: VitalDeviationFinding[] = []; const returns: VitalReturnFinding[] = []; - const redFlags: IllnessRedFlag[] = []; for (const { series, band } of banded) { const { type } = series; @@ -355,39 +380,52 @@ export function computeIllnessCorrelation( // 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) { + // The chronological index of the FIRST notably-deviated active day — the + // anchor the recovery search must start AFTER (an early in-band run before + // the vital ever deviated is not a "return", it is the run-up). + let firstDeviationIndex = -1; + active.forEach((p, i) => { 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 (Math.abs(f.deviationSd) >= NOTABLE_SD) { + if (firstDeviationIndex === -1) firstDeviationIndex = i; + if ( + 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); + // 3) Physiological return: only meaningful once the vital has actually + // deviated. Anchor the search AFTER the first notable deviation so an + // early in-band run can never be reported as a return BEFORE the + // deviation (which yielded a spurious negative gap). Excluded for + // CHRONIC_ONGOING (no recovered date by design). + if (firstDeviationIndex >= 0 && window.lifecycle !== "CHRONIC_ONGOING") { + const returnedDay = firstStableReturn( + active, + center, + spread, + firstDeviationIndex, + ); 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); } + // Red-flag escalation runs DECOUPLED from the banded loop: it scans the raw + // episode days for SpO2 + temperature against ABSOLUTE clinical floors, so a + // rock-steady (MAD=0) vital that drops the band filter still escalates — the + // safety-critical output must fire for exactly the low-variance population it + // matters for. The fever path additionally unions the day-log `feverC` + // series so a user journaling fever with no thermometer is still seen. + const redFlags = detectRedFlags(input); + // 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 @@ -423,20 +461,23 @@ export function computeIllnessCorrelation( } /** - * 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. + * First day in `active` (chronological), starting at `fromIndex`, 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. `fromIndex` pins the search to AFTER the first deviation so an + * early in-band run (the run-up to the illness) is never mistaken for a return. */ function firstStableReturn( active: VitalDayPoint[], center: number, spread: number, + fromIndex: 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++) { + for (let i = Math.max(0, fromIndex); i < inBand.length; i++) { if (!inBand[i].in) continue; let run = 0; for (let j = i; j < inBand.length && inBand[j].in; j++) run++; @@ -446,22 +487,62 @@ function firstStableReturn( } /** - * 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. + * Detect the retrospective red flags — DECOUPLED from the banded loop and the + * own-baseline band entirely. SpO2 and temperature escalate against ABSOLUTE + * clinical floors over the RAW episode days (active span), so a rock-steady + * (MAD=0) vital whose band was dropped still escalates. The fever path unions + * the passive BODY_TEMPERATURE series (per-day max when available) with the + * day-log `feverC` series so the canonical journaling-fever path is visible. + * Conservative; 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"); +function detectRedFlags(input: IllnessCorrelationInput): IllnessRedFlag[] { + const { window } = input; + const flags: IllnessRedFlag[] = []; + const inActive = (p: { day: string }) => p.day >= window.onsetDay; + + // SpO2 — sustained low. Mean series is the floor; use per-day-max only when + // it would HELP (SpO2's worst is the min, so means are conservative enough). + const spo2 = input.series.find((s) => s.type === "OXYGEN_SATURATION"); + if (spo2) { + const flag = runFlag( + spo2.episodeDays.filter(inActive), + (v) => v <= SPO2_RED_FLAG, + "sustained_low_spo2", + "OXYGEN_SATURATION", + "min", + ); + if (flag) flags.push(flag); } - if (type === "BODY_TEMPERATURE") { - return runFlag(sorted, (v) => v >= FEVER_RED_FLAG, "sustained_fever", type, "max"); + + // Fever — union the passive temperature series (per-day MAX preferred so an + // evening spike is not masked by a daily mean) with the day-log feverC. + const temp = input.series.find((s) => s.type === "BODY_TEMPERATURE"); + const tempPoints = temp + ? (temp.episodeDayMax ?? temp.episodeDays).filter(inActive) + : []; + const feverByDay = new Map(); + for (const p of tempPoints) { + feverByDay.set(p.day, Math.max(feverByDay.get(p.day) ?? -Infinity, p.mean)); } - return null; + for (const p of input.dayLogFever ?? []) { + if (!inActive(p)) continue; + feverByDay.set(p.day, Math.max(feverByDay.get(p.day) ?? -Infinity, p.feverC)); + } + if (feverByDay.size > 0) { + const fevPoints: VitalDayPoint[] = [...feverByDay.entries()].map( + ([day, mean]) => ({ day, mean }), + ); + const flag = runFlag( + fevPoints, + (v) => v >= FEVER_RED_FLAG, + "sustained_fever", + "BODY_TEMPERATURE", + "max", + ); + if (flag) flags.push(flag); + } + + return flags; } /** Longest consecutive run matching `predicate`; flag when ≥ RED_FLAG_RUN_DAYS. */ From d7a698950c0b1e07d69fc7ac570569e02241302b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 16 Jun 2026 23:18:06 +0200 Subject: [PATCH 2/4] fix(illness): harden the episode + day-log write paths Wrap episode-create in withIdempotency so an iOS retry can't mint duplicates. Enforce resolvedAt >= onsetAt on create, edit, and the resolve verb (a partial edit is checked against the stored onset). Run the day-log upsert, its symptom- link replacement, and the read-back in one transaction and return the full DTO, dropping the route's second round-trip. Restrict symptom severity to 1-3 (a link's presence already means present, so 0 is not a distinct state). Return the standard { deleted: true } envelope from DELETE instead of a bodyless 204. --- .../illness/episodes/[id]/day-logs/route.ts | 7 +- .../illness/episodes/[id]/resolve/route.ts | 16 ++- src/app/api/illness/episodes/[id]/route.ts | 32 ++++- src/app/api/illness/episodes/route.ts | 10 +- .../illness/__tests__/day-log-write.test.ts | 33 ++++- .../episode-window-invariant.test.ts | 67 +++++++++ src/lib/illness/day-log-write.ts | 132 +++++++++++------- src/lib/validations/illness.ts | 50 +++++-- 8 files changed, 266 insertions(+), 81 deletions(-) create mode 100644 src/lib/illness/__tests__/episode-window-invariant.test.ts diff --git a/src/app/api/illness/episodes/[id]/day-logs/route.ts b/src/app/api/illness/episodes/[id]/day-logs/route.ts index ad0f82437..3d5b70de8 100644 --- a/src/app/api/illness/episodes/[id]/day-logs/route.ts +++ b/src/app/api/illness/episodes/[id]/day-logs/route.ts @@ -115,11 +115,6 @@ export const POST = apiHandler( const tz = user.timezone ?? DEFAULT_TIMEZONE; const result = await upsertIllnessDayLog(user.id, id, parsed.data, tz); - const row = await prisma.illnessDayLog.findUniqueOrThrow({ - where: { id: result.id }, - include: dayLogSymptomInclude, - }); - await auditLog("illness.day-log.upsert", { userId: user.id, ipAddress: getClientIp(request), @@ -135,6 +130,6 @@ export const POST = apiHandler( meta: { existed: result.existed }, }); - return apiSuccess(toIllnessDayLogDTO(row), result.existed ? 200 : 201); + return apiSuccess(result.dto, result.existed ? 200 : 201); }, ); diff --git a/src/app/api/illness/episodes/[id]/resolve/route.ts b/src/app/api/illness/episodes/[id]/resolve/route.ts index bcae413c3..e8f38d487 100644 --- a/src/app/api/illness/episodes/[id]/resolve/route.ts +++ b/src/app/api/illness/episodes/[id]/resolve/route.ts @@ -36,7 +36,13 @@ export const PATCH = apiHandler( const { id } = await params; const existing = await prisma.illnessEpisode.findUnique({ where: { id }, - select: { id: true, userId: true, deletedAt: true, lifecycle: true }, + select: { + id: true, + userId: true, + deletedAt: true, + lifecycle: true, + onsetAt: true, + }, }); if (!existing || existing.userId !== user.id || existing.deletedAt !== null) { return apiError("Episode not found", 404); @@ -63,6 +69,14 @@ export const PATCH = apiHandler( ? new Date(parsed.data.resolvedAt) : new Date(); + // An episode can never resolve before it began (the inverted-window guard, + // checked against the stored onset since the body only carries resolvedAt). + if (resolvedAt < existing.onsetAt) { + return apiError("resolvedAt must be on or after onsetAt", 422, { + errorCode: "illness.episode.invalid-window", + }); + } + const updated = await prisma.illnessEpisode.update({ where: { id }, data: { resolvedAt }, diff --git a/src/app/api/illness/episodes/[id]/route.ts b/src/app/api/illness/episodes/[id]/route.ts index fcf29fa78..3bf355ca8 100644 --- a/src/app/api/illness/episodes/[id]/route.ts +++ b/src/app/api/illness/episodes/[id]/route.ts @@ -1,7 +1,8 @@ /** * `GET /api/illness/episodes/{id}` — one owned, live episode. * `PATCH /api/illness/episodes/{id}` — edit it (partial; field-by-field). - * `DELETE /api/illness/episodes/{id}` — soft-delete it (idempotent, 204). + * `DELETE /api/illness/episodes/{id}` — soft-delete it (idempotent; + * returns `{ deleted: true }`). * * Born-gated + owner-scoped. `userId` is narrowed from auth and fed to the * Prisma `where`; the body never carries it. `note` is encrypted at rest. @@ -62,7 +63,13 @@ export const PATCH = apiHandler( const { id } = await params; const existing = await prisma.illnessEpisode.findUnique({ where: { id }, - select: { id: true, userId: true, deletedAt: true }, + select: { + id: true, + userId: true, + deletedAt: true, + onsetAt: true, + resolvedAt: true, + }, }); if (!existing || existing.userId !== user.id || existing.deletedAt !== null) { return apiError("Episode not found", 404); @@ -81,6 +88,23 @@ export const PATCH = apiHandler( } const entry = parsed.data; + // Window invariant against the MERGED state: a partial edit touching only + // one instant is validated against the stored value (the schema already + // catches a both-in-body inversion). + const mergedOnset = + entry.onsetAt !== undefined ? new Date(entry.onsetAt) : existing.onsetAt; + const mergedResolved = + entry.resolvedAt !== undefined + ? entry.resolvedAt + ? new Date(entry.resolvedAt) + : null + : existing.resolvedAt; + if (mergedResolved && mergedResolved < mergedOnset) { + return apiError("resolvedAt must be on or after onsetAt", 422, { + errorCode: "illness.episode.invalid-window", + }); + } + // A re-parent must point at an owned, live episode. if (entry.parentConditionId) { const parent = await prisma.illnessEpisode.findUnique({ @@ -173,6 +197,8 @@ export const DELETE = apiHandler( }, }); - return new Response(null, { status: 204 }); + // Standard `{ deleted: true }` envelope (the cross-module DELETE shape) so + // the client never has to special-case a bodyless 204 here. + return apiSuccess({ deleted: true }); }, ); diff --git a/src/app/api/illness/episodes/route.ts b/src/app/api/illness/episodes/route.ts index 690465643..154e0c880 100644 --- a/src/app/api/illness/episodes/route.ts +++ b/src/app/api/illness/episodes/route.ts @@ -24,6 +24,7 @@ import { safeJson, } from "@/lib/api-response"; import { requireIllnessEnabled } from "@/lib/illness/gate"; +import { withIdempotency } from "@/lib/idempotency"; import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { illnessEpisodeCreateSchema, @@ -67,7 +68,12 @@ export const GET = apiHandler(async (request: NextRequest) => { return apiSuccess(rows.map(toIllnessEpisodeDTO)); }); -export const POST = apiHandler(async (request: NextRequest) => { +// `withIdempotency` lets an iOS retry / double-tap re-send the same +// `Idempotency-Key` without minting a duplicate episode (siblings — labs, +// measurements — wrap their creates the same way). +export const POST = apiHandler(withIdempotency<[NextRequest]>(postEpisode)); + +async function postEpisode(request: NextRequest): Promise { const { user } = await requireAuth(); const gate = await requireIllnessEnabled(user.id); @@ -135,4 +141,4 @@ export const POST = apiHandler(async (request: NextRequest) => { }); return apiSuccess(toIllnessEpisodeDTO(created), 201); -}); +} diff --git a/src/lib/illness/__tests__/day-log-write.test.ts b/src/lib/illness/__tests__/day-log-write.test.ts index 54910cec7..c30cf81ff 100644 --- a/src/lib/illness/__tests__/day-log-write.test.ts +++ b/src/lib/illness/__tests__/day-log-write.test.ts @@ -8,11 +8,21 @@ */ import { beforeEach, describe, expect, it, vi } from "vitest"; -const db = vi.hoisted(() => ({ - illnessSymptom: { findMany: vi.fn() }, - illnessSymptomLink: { deleteMany: vi.fn(), createMany: vi.fn() }, - illnessDayLog: { findUnique: vi.fn(), upsert: vi.fn() }, -})); +const db = vi.hoisted(() => { + const client = { + illnessSymptom: { findMany: vi.fn() }, + illnessSymptomLink: { deleteMany: vi.fn(), createMany: vi.fn() }, + illnessDayLog: { + findUnique: vi.fn(), + upsert: vi.fn(), + findUniqueOrThrow: vi.fn(), + }, + // The upsert helper runs in one interactive transaction; the mock just + // hands the same client through as `tx`. + $transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn(client)), + }; + return client; +}); vi.mock("@/lib/db", () => ({ prisma: db })); vi.mock("@/lib/ai/coach/bytes-codec", () => ({ @@ -30,7 +40,20 @@ import type { IllnessDayLogInput } from "@/lib/validations/illness"; beforeEach(() => { vi.clearAllMocks(); + db.$transaction.mockImplementation(async (fn: (tx: unknown) => unknown) => + fn(db), + ); db.illnessDayLog.upsert.mockResolvedValue({ id: "dl-1" }); + db.illnessDayLog.findUniqueOrThrow.mockResolvedValue({ + id: "dl-1", + episodeId: "ep1", + date: "2026-06-16", + functionalImpact: null, + feverC: null, + noteEncrypted: null, + updatedAt: new Date("2026-06-16T12:00:00Z"), + symptomLinks: [], + }); }); describe("resolveIllnessSymptomIds", () => { diff --git a/src/lib/illness/__tests__/episode-window-invariant.test.ts b/src/lib/illness/__tests__/episode-window-invariant.test.ts new file mode 100644 index 000000000..8fcff5eb4 --- /dev/null +++ b/src/lib/illness/__tests__/episode-window-invariant.test.ts @@ -0,0 +1,67 @@ +/** + * v1.18.1 — the `resolvedAt >= onsetAt` window invariant on the illness + * episode schemas. An episode can never resolve before it began; the schema + * `superRefine` rejects an inverted window when both instants are supplied + * (a partial edit touching only one is checked against the stored row in the + * route). + */ +import { describe, expect, it } from "vitest"; +import { + illnessEpisodeCreateSchema, + illnessEpisodeUpdateSchema, +} from "@/lib/validations/illness"; + +const ONSET = "2026-01-10T08:00:00Z"; +const BEFORE = "2026-01-08T08:00:00Z"; +const AFTER = "2026-01-15T08:00:00Z"; + +describe("illnessEpisodeCreateSchema — window invariant", () => { + it("rejects resolvedAt earlier than onsetAt", () => { + const out = illnessEpisodeCreateSchema.safeParse({ + label: "Cold", + type: "INFECTION", + onsetAt: ONSET, + resolvedAt: BEFORE, + }); + expect(out.success).toBe(false); + if (!out.success) { + expect(out.error.issues.some((i) => i.path.includes("resolvedAt"))).toBe( + true, + ); + } + }); + + it("accepts resolvedAt on/after onsetAt", () => { + const out = illnessEpisodeCreateSchema.safeParse({ + label: "Cold", + type: "INFECTION", + onsetAt: ONSET, + resolvedAt: AFTER, + }); + expect(out.success).toBe(true); + }); + + it("accepts a create with only onsetAt (no resolvedAt)", () => { + const out = illnessEpisodeCreateSchema.safeParse({ + label: "Cold", + type: "INFECTION", + onsetAt: ONSET, + }); + expect(out.success).toBe(true); + }); +}); + +describe("illnessEpisodeUpdateSchema — window invariant", () => { + it("rejects a both-in-body inverted window", () => { + const out = illnessEpisodeUpdateSchema.safeParse({ + onsetAt: ONSET, + resolvedAt: BEFORE, + }); + expect(out.success).toBe(false); + }); + + it("accepts a partial edit touching only resolvedAt (route merges vs stored)", () => { + const out = illnessEpisodeUpdateSchema.safeParse({ resolvedAt: AFTER }); + expect(out.success).toBe(true); + }); +}); diff --git a/src/lib/illness/day-log-write.ts b/src/lib/illness/day-log-write.ts index 3210c199e..82f3060f2 100644 --- a/src/lib/illness/day-log-write.ts +++ b/src/lib/illness/day-log-write.ts @@ -20,10 +20,15 @@ import { encryptToBytes, } from "@/lib/ai/coach/bytes-codec"; import type { IllnessDayLogInput } from "@/lib/validations/illness"; +import { dayLogSymptomInclude, toIllnessDayLogDTO } from "@/lib/illness/dto"; +import type { IllnessDayLogDTO } from "@/lib/illness/dto"; +import type { Prisma } from "@/generated/prisma/client"; export interface IllnessDayLogWriteResult { id: string; existed: boolean; + /** The full DTO of the written row — no second round-trip needed. */ + dto: IllnessDayLogDTO; } /** A symptom selection carrying its catalog key + optional 0–3 severity. */ @@ -32,16 +37,20 @@ export interface IllnessSymptomSelection { severity?: number | null; } +/** A Prisma client OR an interactive-transaction client. */ +type Db = typeof prisma | Prisma.TransactionClient; + /** * Resolve catalog symptom keys → `{ key, id }`, dropping unknown / inactive * keys silently. */ export async function resolveIllnessSymptomIds( keys: readonly string[], + db: Db = prisma, ): Promise<{ key: string; id: string }[]> { if (keys.length === 0) return []; const unique = Array.from(new Set(keys)); - const rows = await prisma.illnessSymptom.findMany({ + const rows = await db.illnessSymptom.findMany({ where: { key: { in: unique }, isActive: true }, select: { id: true, key: true }, }); @@ -50,14 +59,18 @@ export async function resolveIllnessSymptomIds( /** * Replace the symptom-link set for a day-log. Each selection's optional - * 0–3 severity is persisted on the link (NULL = a plain presence link). + * 1–3 severity is persisted on the link (NULL = a plain presence link). + * Runs on the caller's client so it can join the day-log upsert in one + * transaction (no torn state between the delete + create halves). */ export async function replaceIllnessSymptomLinks( dayLogId: string, selections: readonly IllnessSymptomSelection[], + db: Db = prisma, ): Promise { const resolved = await resolveIllnessSymptomIds( selections.map((s) => s.key), + db, ); const severityByKey = new Map( selections.map((s) => [ @@ -65,9 +78,9 @@ export async function replaceIllnessSymptomLinks( typeof s.severity === "number" ? s.severity : null, ]), ); - await prisma.illnessSymptomLink.deleteMany({ where: { dayLogId } }); + await db.illnessSymptomLink.deleteMany({ where: { dayLogId } }); if (resolved.length > 0) { - await prisma.illnessSymptomLink.createMany({ + await db.illnessSymptomLink.createMany({ data: resolved.map(({ key, id }) => ({ dayLogId, symptomId: id, @@ -101,55 +114,68 @@ export async function upsertIllnessDayLog( ): Promise { const where = { episodeId_date: { episodeId, date: entry.date } }; - const existing = await prisma.illnessDayLog.findUnique({ - where, - select: { - id: true, - functionalImpact: true, - feverC: true, - noteEncrypted: true, - }, + // The upsert, the symptom-link replacement, and the final include-read all + // run in ONE interactive transaction: no torn state between the link delete + // + create halves, and no second round-trip — the written row (with its + // flattened symptom links) comes straight back as the DTO. + const { row, existed } = await prisma.$transaction(async (tx) => { + const existing = await tx.illnessDayLog.findUnique({ + where, + select: { + id: true, + functionalImpact: true, + feverC: true, + noteEncrypted: true, + }, + }); + + // Partial merge against the stored row. + const functionalImpact = + entry.functionalImpact !== undefined + ? (entry.functionalImpact ?? null) + : (existing?.functionalImpact ?? null); + const feverC = + entry.feverC !== undefined + ? (entry.feverC ?? null) + : (existing?.feverC ?? null); + + const incomingNote = + entry.note !== undefined + ? (entry.note ?? null) + : decryptNoteSoft(existing?.noteEncrypted ?? null); + const noteEncrypted = incomingNote ? encryptToBytes(incomingNote) : null; + + const upserted = await tx.illnessDayLog.upsert({ + where, + create: { + userId, + episodeId, + date: entry.date, + tz, + functionalImpact, + feverC, + noteEncrypted, + }, + update: { + functionalImpact, + feverC, + noteEncrypted, + deletedAt: null, + }, + select: { id: true }, + }); + + if (entry.symptoms !== undefined) { + await replaceIllnessSymptomLinks(upserted.id, entry.symptoms, tx); + } + + const full = await tx.illnessDayLog.findUniqueOrThrow({ + where: { id: upserted.id }, + include: dayLogSymptomInclude, + }); + + return { row: full, existed: existing !== null }; }); - // Partial merge against the stored row. - const functionalImpact = - entry.functionalImpact !== undefined - ? (entry.functionalImpact ?? null) - : (existing?.functionalImpact ?? null); - const feverC = - entry.feverC !== undefined - ? (entry.feverC ?? null) - : (existing?.feverC ?? null); - - const incomingNote = - entry.note !== undefined - ? (entry.note ?? null) - : decryptNoteSoft(existing?.noteEncrypted ?? null); - const noteEncrypted = incomingNote ? encryptToBytes(incomingNote) : null; - - const row = await prisma.illnessDayLog.upsert({ - where, - create: { - userId, - episodeId, - date: entry.date, - tz, - functionalImpact, - feverC, - noteEncrypted, - }, - update: { - functionalImpact, - feverC, - noteEncrypted, - deletedAt: null, - }, - select: { id: true }, - }); - - if (entry.symptoms !== undefined) { - await replaceIllnessSymptomLinks(row.id, entry.symptoms); - } - - return { id: row.id, existed: existing !== null }; + return { id: row.id, existed, dto: toIllnessDayLogDTO(row) }; } diff --git a/src/lib/validations/illness.ts b/src/lib/validations/illness.ts index 5af3fc615..83b61e870 100644 --- a/src/lib/validations/illness.ts +++ b/src/lib/validations/illness.ts @@ -57,15 +57,28 @@ export const illnessLifecycleEnum = z.enum([ * at rest. `parentConditionId` threads a FLARE/RECURRING bout under a * parent condition. `onsetAt` defaults to "now" server-side when omitted. */ -export const illnessEpisodeCreateSchema = z.object({ - label: z.string().min(1).max(120), - type: illnessTypeEnum, - lifecycle: illnessLifecycleEnum.optional().default("ACUTE"), - onsetAt: boundedInstant.optional(), - resolvedAt: boundedInstant.nullable().optional(), - parentConditionId: z.string().min(1).max(40).nullable().optional(), - note: z.string().max(2000).nullable().optional(), -}); +export const illnessEpisodeCreateSchema = z + .object({ + label: z.string().min(1).max(120), + type: illnessTypeEnum, + lifecycle: illnessLifecycleEnum.optional().default("ACUTE"), + onsetAt: boundedInstant.optional(), + resolvedAt: boundedInstant.nullable().optional(), + parentConditionId: z.string().min(1).max(40).nullable().optional(), + note: z.string().max(2000).nullable().optional(), + }) + // An episode can never resolve before it began. `onsetAt` defaults to "now" + // server-side when omitted, so the invariant only bites when BOTH instants + // are supplied (the inverted-window case). + .superRefine((v, ctx) => { + if (v.resolvedAt && v.onsetAt && v.resolvedAt < v.onsetAt) { + ctx.addIssue({ + code: "custom", + path: ["resolvedAt"], + message: "resolvedAt must be on or after onsetAt", + }); + } + }); export type IllnessEpisodeCreate = z.infer; @@ -83,7 +96,19 @@ export const illnessEpisodeUpdateSchema = z parentConditionId: z.string().min(1).max(40).nullable().optional(), note: z.string().max(2000).nullable().optional(), }) - .strict(); + .strict() + // When BOTH instants are in the same edit body, enforce the window order at + // parse time. A partial edit touching only one is validated against the + // stored value in the route (it must load the row anyway). + .superRefine((v, ctx) => { + if (v.resolvedAt && v.onsetAt && v.resolvedAt < v.onsetAt) { + ctx.addIssue({ + code: "custom", + path: ["resolvedAt"], + message: "resolvedAt must be on or after onsetAt", + }); + } + }); export type IllnessEpisodeUpdate = z.infer; @@ -120,7 +145,10 @@ export const illnessDayLogInputSchema = z.object({ .array( z.object({ key: z.string().min(1).max(80), - severity: z.number().int().min(0).max(3).optional(), + // 1–3 graded intensity (Jackson/WURSS). A link's mere PRESENCE already + // means "present"; `null`/omitted = a plain presence link, so 0 is not + // a distinct state and the selector offers 1–3 to match this contract. + severity: z.number().int().min(1).max(3).optional(), }), ) .max(40) From c243f6a963bd8c9759078ddcecba0656a7bee90e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 16 Jun 2026 23:18:06 +0200 Subject: [PATCH 3/4] feat(illness): episode detail, retrospective surface, edit + delete guard Add an episode-detail page at /illness/[id] mirroring the lab-biomarker detail: it renders the per-episode correlation card (recovery gap, red flags, pre-onset and nadir findings, or 'still learning' when the signal is thin) and surfaces the stored note. List rows link into it; the cross-episode retrospective summary sits below the history. Fold the row's inline buttons into a shared overflow menu and gate delete behind a confirmation dialog. Let the new-episode sheet double as an edit form, add the FLARE course and a parent-condition picker, and let the day-log sheet backdate within the episode window. Swap the loading and empty states onto the shared primitives. --- messages/de.json | 36 ++++- messages/en.json | 36 ++++- messages/es.json | 36 ++++- messages/fr.json | 36 ++++- messages/it.json | 36 ++++- messages/pl.json | 36 ++++- src/app/illness/[id]/page.tsx | 56 +++++++ src/components/illness/episode-menu.tsx | 129 +++++++++++++++ .../illness/illness-correlation-card.tsx | 151 ++++++++++++++++++ .../illness/illness-episode-detail.tsx | 142 ++++++++++++++++ .../illness/illness-insights-card.tsx | 64 ++++++++ src/components/illness/illness-view.tsx | 143 +++++++++-------- src/components/illness/log-day-sheet.tsx | 57 ++++++- src/components/illness/new-episode-sheet.tsx | 112 ++++++++++--- src/components/illness/types.ts | 77 +++++++++ src/components/illness/use-illness.ts | 50 ++++++ 16 files changed, 1092 insertions(+), 105 deletions(-) create mode 100644 src/app/illness/[id]/page.tsx create mode 100644 src/components/illness/episode-menu.tsx create mode 100644 src/components/illness/illness-correlation-card.tsx create mode 100644 src/components/illness/illness-episode-detail.tsx create mode 100644 src/components/illness/illness-insights-card.tsx diff --git a/messages/de.json b/messages/de.json index d5421a396..7d29ecef3 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6827,7 +6827,9 @@ "onset": "Begonnen am", "note": "Notiz", "notePlaceholder": "Was erwähnenswert ist (verschlüsselt).", - "saveError": "Die Episode konnte nicht gespeichert werden. Bitte erneut versuchen." + "saveError": "Die Episode konnte nicht gespeichert werden. Bitte erneut versuchen.", + "parent": "Part of", + "parentNone": "Not linked" }, "sheet": { "title": "{date} erfassen", @@ -6839,7 +6841,8 @@ "noteSet": "Gesetzt", "notePlaceholder": "Wie sich der Tag anfühlte (verschlüsselt).", "severityFor": "Stärke für {symptom}", - "saveError": "Der Tag konnte nicht gespeichert werden. Bitte erneut versuchen." + "saveError": "Der Tag konnte nicht gespeichert werden. Bitte erneut versuchen.", + "day": "Day" }, "impact": { "0": "Voll belastbar", @@ -6884,6 +6887,35 @@ "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." + }, + "loadError": "Could not load this episode.", + "backToList": "Back to journal", + "detail": { + "note": "Note" + }, + "edit": { + "title": "Edit episode", + "description": "Update this episode's details." + }, + "menu": { + "label": "Episode actions" + }, + "deleteConfirm": { + "title": "Delete this episode?", + "body": "“{label}” and all its day-logs will be removed. This cannot be undone." + }, + "vital": { + "RESTING_HEART_RATE": "Resting heart rate", + "HEART_RATE_VARIABILITY": "Heart-rate variability", + "RECOVERY_SCORE": "Recovery", + "OXYGEN_SATURATION": "Oxygen saturation", + "BODY_TEMPERATURE": "Body temperature", + "SKIN_TEMPERATURE": "Skin temperature", + "RESPIRATORY_RATE": "Respiratory rate", + "BLOOD_PRESSURE_SYS": "Systolic blood pressure", + "BLOOD_PRESSURE_DIA": "Diastolic blood pressure", + "PULSE": "Pulse", + "WEIGHT": "Weight" } }, "coach": { diff --git a/messages/en.json b/messages/en.json index a1495e837..392904f90 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6827,7 +6827,9 @@ "onset": "Started on", "note": "Note", "notePlaceholder": "Anything worth remembering (encrypted).", - "saveError": "Could not save the episode. Please try again." + "saveError": "Could not save the episode. Please try again.", + "parent": "Part of", + "parentNone": "Not linked" }, "sheet": { "title": "Log {date}", @@ -6839,7 +6841,8 @@ "noteSet": "Set", "notePlaceholder": "How the day felt (encrypted).", "severityFor": "Severity for {symptom}", - "saveError": "Could not save the day. Please try again." + "saveError": "Could not save the day. Please try again.", + "day": "Day" }, "impact": { "0": "Fully functional", @@ -6884,6 +6887,35 @@ "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." + }, + "loadError": "Could not load this episode.", + "backToList": "Back to journal", + "detail": { + "note": "Note" + }, + "edit": { + "title": "Edit episode", + "description": "Update this episode's details." + }, + "menu": { + "label": "Episode actions" + }, + "deleteConfirm": { + "title": "Delete this episode?", + "body": "“{label}” and all its day-logs will be removed. This cannot be undone." + }, + "vital": { + "RESTING_HEART_RATE": "Resting heart rate", + "HEART_RATE_VARIABILITY": "Heart-rate variability", + "RECOVERY_SCORE": "Recovery", + "OXYGEN_SATURATION": "Oxygen saturation", + "BODY_TEMPERATURE": "Body temperature", + "SKIN_TEMPERATURE": "Skin temperature", + "RESPIRATORY_RATE": "Respiratory rate", + "BLOOD_PRESSURE_SYS": "Systolic blood pressure", + "BLOOD_PRESSURE_DIA": "Diastolic blood pressure", + "PULSE": "Pulse", + "WEIGHT": "Weight" } }, "coach": { diff --git a/messages/es.json b/messages/es.json index 6c88b63ae..0adc83cdd 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6827,7 +6827,9 @@ "onset": "Comenzó el", "note": "Nota", "notePlaceholder": "Algo que valga la pena recordar (cifrado).", - "saveError": "No se pudo guardar el episodio. Inténtalo de nuevo." + "saveError": "No se pudo guardar el episodio. Inténtalo de nuevo.", + "parent": "Part of", + "parentNone": "Not linked" }, "sheet": { "title": "Registrar {date}", @@ -6839,7 +6841,8 @@ "noteSet": "Definida", "notePlaceholder": "Cómo te sentiste hoy (cifrado).", "severityFor": "Gravedad de {symptom}", - "saveError": "No se pudo guardar el día. Inténtalo de nuevo." + "saveError": "No se pudo guardar el día. Inténtalo de nuevo.", + "day": "Day" }, "impact": { "0": "Plenamente funcional", @@ -6884,6 +6887,35 @@ "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." + }, + "loadError": "Could not load this episode.", + "backToList": "Back to journal", + "detail": { + "note": "Note" + }, + "edit": { + "title": "Edit episode", + "description": "Update this episode's details." + }, + "menu": { + "label": "Episode actions" + }, + "deleteConfirm": { + "title": "Delete this episode?", + "body": "“{label}” and all its day-logs will be removed. This cannot be undone." + }, + "vital": { + "RESTING_HEART_RATE": "Resting heart rate", + "HEART_RATE_VARIABILITY": "Heart-rate variability", + "RECOVERY_SCORE": "Recovery", + "OXYGEN_SATURATION": "Oxygen saturation", + "BODY_TEMPERATURE": "Body temperature", + "SKIN_TEMPERATURE": "Skin temperature", + "RESPIRATORY_RATE": "Respiratory rate", + "BLOOD_PRESSURE_SYS": "Systolic blood pressure", + "BLOOD_PRESSURE_DIA": "Diastolic blood pressure", + "PULSE": "Pulse", + "WEIGHT": "Weight" } }, "coach": { diff --git a/messages/fr.json b/messages/fr.json index 079a2b06f..bbc1fc48f 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6827,7 +6827,9 @@ "onset": "Débuté le", "note": "Note", "notePlaceholder": "Ce qui mérite d'être noté (chiffré).", - "saveError": "Impossible d'enregistrer l'épisode. Veuillez réessayer." + "saveError": "Impossible d'enregistrer l'épisode. Veuillez réessayer.", + "parent": "Part of", + "parentNone": "Not linked" }, "sheet": { "title": "Enregistrer {date}", @@ -6839,7 +6841,8 @@ "noteSet": "Définie", "notePlaceholder": "Comment s'est passée la journée (chiffré).", "severityFor": "Intensité de {symptom}", - "saveError": "Impossible d'enregistrer la journée. Veuillez réessayer." + "saveError": "Impossible d'enregistrer la journée. Veuillez réessayer.", + "day": "Day" }, "impact": { "0": "Pleinement fonctionnel", @@ -6884,6 +6887,35 @@ "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." + }, + "loadError": "Could not load this episode.", + "backToList": "Back to journal", + "detail": { + "note": "Note" + }, + "edit": { + "title": "Edit episode", + "description": "Update this episode's details." + }, + "menu": { + "label": "Episode actions" + }, + "deleteConfirm": { + "title": "Delete this episode?", + "body": "“{label}” and all its day-logs will be removed. This cannot be undone." + }, + "vital": { + "RESTING_HEART_RATE": "Resting heart rate", + "HEART_RATE_VARIABILITY": "Heart-rate variability", + "RECOVERY_SCORE": "Recovery", + "OXYGEN_SATURATION": "Oxygen saturation", + "BODY_TEMPERATURE": "Body temperature", + "SKIN_TEMPERATURE": "Skin temperature", + "RESPIRATORY_RATE": "Respiratory rate", + "BLOOD_PRESSURE_SYS": "Systolic blood pressure", + "BLOOD_PRESSURE_DIA": "Diastolic blood pressure", + "PULSE": "Pulse", + "WEIGHT": "Weight" } }, "coach": { diff --git a/messages/it.json b/messages/it.json index fcea894ef..94a3d7fb5 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6827,7 +6827,9 @@ "onset": "Iniziato il", "note": "Nota", "notePlaceholder": "Qualcosa da ricordare (cifrato).", - "saveError": "Impossibile salvare l'episodio. Riprova." + "saveError": "Impossibile salvare l'episodio. Riprova.", + "parent": "Part of", + "parentNone": "Not linked" }, "sheet": { "title": "Registra {date}", @@ -6839,7 +6841,8 @@ "noteSet": "Impostata", "notePlaceholder": "Com'è andata la giornata (cifrato).", "severityFor": "Intensità di {symptom}", - "saveError": "Impossibile salvare la giornata. Riprova." + "saveError": "Impossibile salvare la giornata. Riprova.", + "day": "Day" }, "impact": { "0": "Pienamente funzionale", @@ -6884,6 +6887,35 @@ "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." + }, + "loadError": "Could not load this episode.", + "backToList": "Back to journal", + "detail": { + "note": "Note" + }, + "edit": { + "title": "Edit episode", + "description": "Update this episode's details." + }, + "menu": { + "label": "Episode actions" + }, + "deleteConfirm": { + "title": "Delete this episode?", + "body": "“{label}” and all its day-logs will be removed. This cannot be undone." + }, + "vital": { + "RESTING_HEART_RATE": "Resting heart rate", + "HEART_RATE_VARIABILITY": "Heart-rate variability", + "RECOVERY_SCORE": "Recovery", + "OXYGEN_SATURATION": "Oxygen saturation", + "BODY_TEMPERATURE": "Body temperature", + "SKIN_TEMPERATURE": "Skin temperature", + "RESPIRATORY_RATE": "Respiratory rate", + "BLOOD_PRESSURE_SYS": "Systolic blood pressure", + "BLOOD_PRESSURE_DIA": "Diastolic blood pressure", + "PULSE": "Pulse", + "WEIGHT": "Weight" } }, "coach": { diff --git a/messages/pl.json b/messages/pl.json index 1c7b3ed76..99aa20b2c 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6827,7 +6827,9 @@ "onset": "Początek", "note": "Notatka", "notePlaceholder": "Cokolwiek wartego zapamiętania (zaszyfrowane).", - "saveError": "Nie udało się zapisać epizodu. Spróbuj ponownie." + "saveError": "Nie udało się zapisać epizodu. Spróbuj ponownie.", + "parent": "Part of", + "parentNone": "Not linked" }, "sheet": { "title": "Zapisz {date}", @@ -6839,7 +6841,8 @@ "noteSet": "Ustawiona", "notePlaceholder": "Jak minął dzień (zaszyfrowane).", "severityFor": "Nasilenie: {symptom}", - "saveError": "Nie udało się zapisać dnia. Spróbuj ponownie." + "saveError": "Nie udało się zapisać dnia. Spróbuj ponownie.", + "day": "Day" }, "impact": { "0": "Pełna sprawność", @@ -6884,6 +6887,35 @@ "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." + }, + "loadError": "Could not load this episode.", + "backToList": "Back to journal", + "detail": { + "note": "Note" + }, + "edit": { + "title": "Edit episode", + "description": "Update this episode's details." + }, + "menu": { + "label": "Episode actions" + }, + "deleteConfirm": { + "title": "Delete this episode?", + "body": "“{label}” and all its day-logs will be removed. This cannot be undone." + }, + "vital": { + "RESTING_HEART_RATE": "Resting heart rate", + "HEART_RATE_VARIABILITY": "Heart-rate variability", + "RECOVERY_SCORE": "Recovery", + "OXYGEN_SATURATION": "Oxygen saturation", + "BODY_TEMPERATURE": "Body temperature", + "SKIN_TEMPERATURE": "Skin temperature", + "RESPIRATORY_RATE": "Respiratory rate", + "BLOOD_PRESSURE_SYS": "Systolic blood pressure", + "BLOOD_PRESSURE_DIA": "Diastolic blood pressure", + "PULSE": "Pulse", + "WEIGHT": "Weight" } }, "coach": { diff --git a/src/app/illness/[id]/page.tsx b/src/app/illness/[id]/page.tsx new file mode 100644 index 000000000..8186871ab --- /dev/null +++ b/src/app/illness/[id]/page.tsx @@ -0,0 +1,56 @@ +"use client"; + +/** + * v1.18.1 — per-episode detail page (`/illness/[id]`), mirroring + * `/labs/[biomarkerId]`. Born-gated on the resolved `modules.illness` flag; + * an unauthenticated visitor is bounced to login, an account without the + * module opted in is bounced home (every `/api/illness/*` route also enforces + * the gate server-side, so this is a UX redirect, not the security boundary). + */ +import { use, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { Loader2 } from "lucide-react"; + +import { IllnessEpisodeDetail } from "@/components/illness/illness-episode-detail"; +import { BackLink } from "@/components/ui/back-link"; +import { useAuth } from "@/hooks/use-auth"; +import { useMounted } from "@/hooks/use-mounted"; +import { useTranslations } from "@/lib/i18n/context"; + +export default function IllnessEpisodePage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = use(params); + const { user, isAuthenticated, isLoading } = useAuth(); + const mounted = useMounted(); + const router = useRouter(); + const { t } = useTranslations(); + + const enabled = user?.modules?.illness === true; + + useEffect(() => { + if (isLoading) return; + if (!isAuthenticated) { + router.push("/auth/login"); + } else if (!enabled) { + router.push("/"); + } + }, [isLoading, isAuthenticated, enabled, router]); + + if (!mounted || isLoading || !isAuthenticated || !enabled) { + return ( +
+ +
+ ); + } + + return ( +
+ + +
+ ); +} diff --git a/src/components/illness/episode-menu.tsx b/src/components/illness/episode-menu.tsx new file mode 100644 index 000000000..6fe24c37e --- /dev/null +++ b/src/components/illness/episode-menu.tsx @@ -0,0 +1,129 @@ +"use client"; + +/** + * v1.18.1 — shared illness-episode overflow menu (mirrors the medication + * card menu): one kebab folding Edit / Mark recovered / Delete. Delete is + * gated behind an AlertDialog (every destructive peer guards this; an episode + * delete cascades its day-logs + encrypted notes, so a single unguarded tap + * was the Medium finding). Neutral throughout. + */ +import { useState } from "react"; +import { MoreVertical, Pencil, CheckCircle2, Trash2 } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { useTranslations } from "@/lib/i18n/context"; + +import { useDeleteEpisode } from "./use-illness"; +import type { IllnessEpisodeDTO } from "./types"; + +export interface EpisodeMenuProps { + episode: IllnessEpisodeDTO; + onEdit: () => void; + /** Provided only when the episode is active + non-chronic. */ + onResolve?: () => void; + resolving?: boolean; + /** Called after a confirmed delete (e.g. navigate back from a detail page). */ + onDeleted?: () => void; +} + +export function EpisodeMenu({ + episode, + onEdit, + onResolve, + resolving, + onDeleted, +}: EpisodeMenuProps) { + const { t } = useTranslations(); + const del = useDeleteEpisode(); + const [confirmOpen, setConfirmOpen] = useState(false); + + async function handleConfirmDelete() { + try { + await del.mutateAsync(episode.id); + setConfirmOpen(false); + onDeleted?.(); + } catch { + // Keep the dialog open; the list will surface any error on next read. + } + } + + return ( + <> + + + + + + + + {t("common.edit")} + + {onResolve ? ( + + + {t("illness.markRecovered")} + + ) : null} + + { + e.preventDefault(); + setConfirmOpen(true); + }} + > + + {t("common.delete")} + + + + + + + + + {t("illness.deleteConfirm.title")} + + + {t("illness.deleteConfirm.body", { label: episode.label })} + + + + {t("common.cancel")} + + {t("common.delete")} + + + + + + ); +} diff --git a/src/components/illness/illness-correlation-card.tsx b/src/components/illness/illness-correlation-card.tsx new file mode 100644 index 000000000..389530700 --- /dev/null +++ b/src/components/illness/illness-correlation-card.tsx @@ -0,0 +1,151 @@ +"use client"; + +/** + * v1.18.1 P3 — the per-episode retrospective correlation card ("How it + * unfolded"). Server-authoritative + coverage-gated: it pattern-matches the + * `Derived` `status` and renders "still learning" when the signal is thin, + * NEVER a fabricated number. Retrospective only — never a prediction or a + * diagnosis. Neutral palette throughout (the med-card no-colour rule + * generalised); the red-flag is a calm "worth a closer look" note, framed to + * escalate without alarming colour. + */ +import { ArrowDown, ArrowUp, Info } from "lucide-react"; + +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useTranslations } from "@/lib/i18n/context"; + +import { useIllnessCorrelation } from "./use-illness"; +import type { + IllnessCorrelationValue, + IllnessVitalDeviation, +} from "./types"; + +/** Translate a MeasurementType to its illness-surface label. */ +function useVitalLabel() { + const { t } = useTranslations(); + return (type: string) => t(`illness.vital.${type}`); +} + +function DeviationRow({ d }: { d: IllnessVitalDeviation }) { + const { t } = useTranslations(); + const vitalLabel = useVitalLabel(); + const Arrow = d.direction === "above" ? ArrowUp : ArrowDown; + return ( +
  • + + {vitalLabel(d.type)} + + + + {t(`illness.correlation.direction.${d.direction}`)} + +
  • + ); +} + +function CorrelationBody({ value }: { value: IllnessCorrelationValue }) { + const { t } = useTranslations(); + const gap = value.recoveryGapDays; + + return ( +
    + {value.redFlags.length > 0 ? ( +
    +

    + {t("illness.correlation.redFlagTitle")} +

    +
      + {value.redFlags.map((f) => ( +
    • + {f.reason === "sustained_low_spo2" + ? t("illness.correlation.redFlagSpo2") + : t("illness.correlation.redFlagFever")} +
    • + ))} +
    +
    + ) : null} + + {gap !== null ? ( +
    +

    + {t("illness.correlation.recoveryGapTitle")} +

    +

    + {gap > 0 + ? t("illness.correlation.recoveryGapLag", { days: gap }) + : gap < 0 + ? t("illness.correlation.recoveryGapLead", { days: Math.abs(gap) }) + : t("illness.correlation.recoveryGapSame")} +

    +
    + ) : null} + + {value.preOnset.length > 0 ? ( +
    +

    + {t("illness.correlation.preOnsetTitle")} +

    +

    + {t("illness.correlation.preOnsetBody")} +

    +
      + {value.preOnset.map((d) => ( + + ))} +
    +
    + ) : null} + + {value.nadir.length > 0 ? ( +
    +

    + {t("illness.correlation.nadirTitle")} +

    +

    + {t("illness.correlation.nadirBody")} +

    +
      + {value.nadir.map((d) => ( + + ))} +
    +
    + ) : null} +
    + ); +} + +export function IllnessCorrelationCard({ episodeId }: { episodeId: string }) { + const { t } = useTranslations(); + const { data, isLoading, isError } = useIllnessCorrelation(episodeId); + + return ( + + +
    + +

    + {t("illness.correlation.title")} +

    +
    +
    + + {isLoading ? ( +
    + + + +
    + ) : isError || !data || data.status !== "ok" || !data.value ? ( +

    + {t("illness.correlation.learning")} +

    + ) : ( + + )} +
    +
    + ); +} diff --git a/src/components/illness/illness-episode-detail.tsx b/src/components/illness/illness-episode-detail.tsx new file mode 100644 index 000000000..4ad5f5e7f --- /dev/null +++ b/src/components/illness/illness-episode-detail.tsx @@ -0,0 +1,142 @@ +"use client"; + +/** + * v1.18.1 — per-episode detail surface (`/illness/[id]`), mirroring + * `/labs/[biomarkerId]`. Carries the heading + neutral status, a "log a day" + * entry, the stored note, an Edit affordance (the same capture sheet + * pre-filled), and the retrospective correlation card (recovery-gap / + * red-flag / pre-onset / nadir). Calm + retrospective — no prediction UI, no + * alarming colour. + */ +import { useState } from "react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useTranslations, useFormatters } from "@/lib/i18n/context"; + +import { IllnessCorrelationCard } from "./illness-correlation-card"; +import { LogDaySheet } from "./log-day-sheet"; +import { NewEpisodeSheet } from "./new-episode-sheet"; +import { EpisodeMenu } from "./episode-menu"; +import { useIllnessEpisode, useResolveEpisode } from "./use-illness"; + +/** Today as YYYY-MM-DD in the viewer's local calendar. */ +function todayLocal(): string { + const now = new Date(); + const y = now.getFullYear(); + const m = String(now.getMonth() + 1).padStart(2, "0"); + const d = String(now.getDate()).padStart(2, "0"); + return `${y}-${m}-${d}`; +} + +export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) { + const { t } = useTranslations(); + const fmt = useFormatters(); + const { data: episode, isLoading, isError } = useIllnessEpisode(episodeId); + const resolve = useResolveEpisode(); + + const [logOpen, setLogOpen] = useState(false); + const [editOpen, setEditOpen] = useState(false); + const today = todayLocal(); + + if (isError) { + return ( +

    + {t("illness.loadError")} +

    + ); + } + + const active = episode ? episode.resolvedAt === null : false; + const isChronic = episode?.lifecycle === "CHRONIC_ONGOING"; + + return ( +
    +
    +
    +

    + {episode?.label ?? } +

    + {episode ? ( +
    + + {t(`illness.type.${episode.type}`)} + + + {active + ? isChronic + ? t("illness.status.ongoing") + : t("illness.status.active") + : t("illness.status.recovered")} + + + {t("illness.onsetOn", { + date: fmt.dateShort(new Date(episode.onsetAt)), + })} + {episode.resolvedAt + ? ` · ${t("illness.recoveredOn", { + date: fmt.dateShort(new Date(episode.resolvedAt)), + })}` + : ""} + +
    + ) : null} +
    +
    + + {episode ? ( + setEditOpen(true)} + onResolve={ + active && !isChronic ? () => resolve.mutate(episode.id) : undefined + } + resolving={resolve.isPending} + /> + ) : null} +
    +
    + + {episode?.note ? ( + + +

    + {t("illness.detail.note")} +

    +

    + {episode.note} +

    +
    +
    + ) : null} + + {isLoading ? ( + + ) : ( + + )} + + {episode ? ( + <> + + + + ) : null} +
    + ); +} diff --git a/src/components/illness/illness-insights-card.tsx b/src/components/illness/illness-insights-card.tsx new file mode 100644 index 000000000..fd627f385 --- /dev/null +++ b/src/components/illness/illness-insights-card.tsx @@ -0,0 +1,64 @@ +"use client"; + +/** + * v1.18.1 P3 — the cross-episode retrospective summary card ("Your illness + * history"): how many episodes in the window + the typical recovery gap, with + * a calm "still learning" line until enough resolved episodes back the figure. + * Retrospective only — a count of the past, never a forecast. + */ +import { History } from "lucide-react"; + +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useTranslations } from "@/lib/i18n/context"; + +import { useIllnessInsights } from "./use-illness"; + +export function IllnessInsightsCard({ windowDays = 365 }: { windowDays?: number }) { + const { t } = useTranslations(); + const { data, isLoading, isError } = useIllnessInsights(windowDays); + + return ( + + +
    + +

    + {t("illness.insights.title")} +

    +
    +
    + + {isLoading ? ( +
    + + +
    + ) : isError || !data ? ( +

    + {t("illness.insights.none")} +

    + ) : data.episodeCount === 0 ? ( +

    + {t("illness.insights.none")} +

    + ) : ( +
    +

    + {data.episodeCount === 1 + ? t("illness.insights.countOne") + : t("illness.insights.countOther", { count: data.episodeCount })} +

    +

    + {data.typicalRecoveryGapDays !== null + ? t("illness.insights.typicalGap", { + days: data.typicalRecoveryGapDays, + }) + : t("illness.insights.typicalGapLearning")} +

    +
    + )} +
    +
    + ); +} diff --git a/src/components/illness/illness-view.tsx b/src/components/illness/illness-view.tsx index deaa3c10d..890bdda9e 100644 --- a/src/components/illness/illness-view.tsx +++ b/src/components/illness/illness-view.tsx @@ -5,27 +5,28 @@ * * A calm, retrospective journal: the episode history (newest first) with a * neutral status Badge (active / recovered — never an alarming colour, the - * med-card rule generalised), a "log a day" entry into the day-log sheet, - * and a "mark recovered" one-tap. Retrospective-only copy: this is a - * journal, not a medical device, and it does not diagnose. No prediction - * UI by design. + * med-card rule generalised), a shared overflow kebab (Edit / Mark recovered / + * AlertDialog-gated Delete), a "log a day" entry, and the cross-episode + * retrospective summary. Each row navigates to the episode-detail surface + * (`/illness/[id]`) for the correlation card. Retrospective-only copy — a + * journal, not a medical device, and it does not diagnose. */ import { useState } from "react"; -import { Plus, Stethoscope } from "lucide-react"; +import Link from "next/link"; +import { ChevronRight, Plus, Stethoscope } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; -import { useTranslations } from "@/lib/i18n/context"; -import { useFormatters } from "@/lib/i18n/context"; +import { EmptyState } from "@/components/ui/empty-state"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { LogDaySheet } from "./log-day-sheet"; import { NewEpisodeSheet } from "./new-episode-sheet"; -import { - useDeleteEpisode, - useIllnessEpisodes, - useResolveEpisode, -} from "./use-illness"; +import { EpisodeMenu } from "./episode-menu"; +import { IllnessInsightsCard } from "./illness-insights-card"; +import { useIllnessEpisodes, useResolveEpisode } from "./use-illness"; import type { IllnessEpisodeDTO } from "./types"; /** Today as a YYYY-MM-DD string in the viewer's local calendar. */ @@ -40,14 +41,14 @@ function todayLocal(): string { function EpisodeRow({ episode, onLogDay, + onEdit, onResolve, - onDelete, resolving, }: { episode: IllnessEpisodeDTO; onLogDay: (id: string) => void; + onEdit: (episode: IllnessEpisodeDTO) => void; onResolve: (id: string) => void; - onDelete: (id: string) => void; resolving: boolean; }) { const { t } = useTranslations(); @@ -56,8 +57,8 @@ function EpisodeRow({ const isChronic = episode.lifecycle === "CHRONIC_ONGOING"; return ( - -
    + +
    {episode.label} {t(`illness.type.${episode.type}`)} @@ -77,29 +78,28 @@ function EpisodeRow({ })}` : ""}

    -
    -
    - - {active && !isChronic ? ( - - ) : null} - + onEdit(episode)} + onResolve={ + active && !isChronic ? () => onResolve(episode.id) : undefined + } + resolving={resolving} + /> +
    ); @@ -109,12 +109,14 @@ export function IllnessView() { const { t } = useTranslations(); const { data: episodes, isLoading } = useIllnessEpisodes(true); const resolve = useResolveEpisode(); - const del = useDeleteEpisode(); const today = todayLocal(); const [newOpen, setNewOpen] = useState(false); + const [editEpisode, setEditEpisode] = useState(null); const [logEpisodeId, setLogEpisodeId] = useState(null); + const logEpisode = episodes?.find((e) => e.id === logEpisodeId) ?? null; + return (
    @@ -138,37 +140,51 @@ export function IllnessView() {

    {t("illness.disclaimer")}

    {isLoading ? ( -

    {t("common.loading")}

    - ) : episodes && episodes.length > 0 ? (
    - {episodes.map((episode) => ( - setLogEpisodeId(id)} - onResolve={(id) => resolve.mutate(id)} - onDelete={(id) => del.mutate(id)} - /> - ))} + + +
    - ) : ( - - -
    -

    {t("illness.empty.title")}

    -

    - {t("illness.empty.body")} -

    + ) : episodes && episodes.length > 0 ? ( + <> +
    + {episodes.map((episode) => ( + setLogEpisodeId(id)} + onEdit={(e) => setEditEpisode(e)} + onResolve={(id) => resolve.mutate(id)} + /> + ))}
    - - + + + ) : ( + } + title={t("illness.empty.title")} + description={t("illness.empty.body")} + ctaSize="lg" + action={ + + } + /> )} + { + if (!open) setEditEpisode(null); + }} + today={today} + editEpisode={editEpisode ?? undefined} + /> {logEpisodeId ? ( ) : null}
    diff --git a/src/components/illness/log-day-sheet.tsx b/src/components/illness/log-day-sheet.tsx index d2d309f0c..4f59518ec 100644 --- a/src/components/illness/log-day-sheet.tsx +++ b/src/components/illness/log-day-sheet.tsx @@ -14,7 +14,7 @@ * generalised). */ import { useMemo, useState } from "react"; -import { Activity, NotebookPen, Thermometer } from "lucide-react"; +import { Activity, CalendarDays, NotebookPen, Thermometer } from "lucide-react"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { @@ -36,7 +36,14 @@ interface LogDaySheetProps { open: boolean; onOpenChange: (open: boolean) => void; episodeId: string; + /** The day to seed the picker with (usually today). */ date: string; + /** + * Episode onset day (`YYYY-MM-DD`) — the earliest day the picker allows, so + * a retrospective journal can backdate a day within the episode window + * (a user ill Sat–Sun who opens Monday can still record either day). + */ + onsetDate?: string; } /** Mini toggle chip — the shared selection vocabulary. */ @@ -132,9 +139,23 @@ export function LogDaySheet({ onOpenChange, episodeId, date, + onsetDate, }: LogDaySheetProps) { const { t } = useTranslations(); - const dayLog = useIllnessDayLog(open ? episodeId : null, date); + + // The day being logged — seeded from `date` (today), but the picker lets the + // user backdate within [onset, today] for the retrospective journal. + const [selectedDate, setSelectedDate] = useState(date); + // Re-seed the picker each time the sheet opens (the open transition). + const [pickerWasOpen, setPickerWasOpen] = useState(false); + if (open && !pickerWasOpen) { + setPickerWasOpen(true); + setSelectedDate(date); + } else if (!open && pickerWasOpen) { + setPickerWasOpen(false); + } + + const dayLog = useIllnessDayLog(open ? episodeId : null, selectedDate); const upsert = useUpsertDayLog(episodeId); const [symptoms, setSymptoms] = useState>( @@ -150,7 +171,7 @@ export function LogDaySheet({ // `dto === null` (nothing logged) resets to blank. const dto = open ? (dayLog.data ?? null) : undefined; const formKey = open - ? `${episodeId}:${date}:${dayLog.isSuccess ? "loaded" : "loading"}` + ? `${episodeId}:${selectedDate}:${dayLog.isSuccess ? "loaded" : "loading"}` : null; const [lastFormKey, setLastFormKey] = useState(null); if (open && formKey !== lastFormKey && !dayLog.isLoading) { @@ -192,7 +213,7 @@ export function LogDaySheet({ async function handleSave() { const input: IllnessDayLogInput = { - date, + date: selectedDate, functionalImpact, feverC: feverValue, symptoms: Array.from(symptoms.entries()).map(([key, severity]) => ({ @@ -220,7 +241,7 @@ export function LogDaySheet({ @@ -234,6 +255,32 @@ export function LogDaySheet({ } >
    + } + summary={ + {selectedDate} + } + defaultOpen + > +
    + + { + if (e.target.value) setSelectedDate(e.target.value); + }} + className="max-w-44" + /> +
    +
    + } diff --git a/src/components/illness/new-episode-sheet.tsx b/src/components/illness/new-episode-sheet.tsx index adfc44189..b312d6512 100644 --- a/src/components/illness/new-episode-sheet.tsx +++ b/src/components/illness/new-episode-sheet.tsx @@ -22,8 +22,12 @@ import { } from "@/components/ui/select"; import { useTranslations } from "@/lib/i18n/context"; -import { useCreateEpisode } from "./use-illness"; -import type { IllnessLifecycle, IllnessType } from "./types"; +import { useCreateEpisode, useIllnessEpisodes, useUpdateEpisode } from "./use-illness"; +import type { + IllnessEpisodeDTO, + IllnessLifecycle, + IllnessType, +} from "./types"; const TYPES: IllnessType[] = [ "INFECTION", @@ -39,55 +43,95 @@ const LIFECYCLES: IllnessLifecycle[] = [ "ACUTE", "CHRONIC_ONGOING", "RECURRING", + "FLARE", ]; +/** Lifecycles that may hang off a parent condition. */ +const PARENTABLE: IllnessLifecycle[] = ["FLARE", "RECURRING"]; + +const NONE = "__none__"; + interface NewEpisodeSheetProps { open: boolean; onOpenChange: (open: boolean) => void; today: string; + /** When set, the sheet edits this episode instead of creating a new one. */ + editEpisode?: IllnessEpisodeDTO; } export function NewEpisodeSheet({ open, onOpenChange, today, + editEpisode, }: NewEpisodeSheetProps) { const { t } = useTranslations(); const create = useCreateEpisode(); + const update = useUpdateEpisode(); + const isEdit = editEpisode !== undefined; + const pending = create.isPending || update.isPending; + const isError = create.isError || update.isError; + + // Candidate parents: live episodes other than the one being edited. + const { data: allEpisodes } = useIllnessEpisodes(true); + const parentCandidates = (allEpisodes ?? []).filter( + (e) => e.id !== editEpisode?.id, + ); const [label, setLabel] = useState(""); const [type, setType] = useState("INFECTION"); const [lifecycle, setLifecycle] = useState("ACUTE"); const [onset, setOnset] = useState(today); + const [parentId, setParentId] = useState(NONE); const [note, setNote] = useState(""); - // Reset the form each time the sheet opens — adjusted during render keyed - // on the open transition (React's recommended alternative to a + // Reset / hydrate the form each time the sheet opens — adjusted during + // render keyed on the open transition (React's recommended alternative to a // setState-in-effect). const [wasOpen, setWasOpen] = useState(false); if (open && !wasOpen) { setWasOpen(true); - setLabel(""); - setType("INFECTION"); - setLifecycle("ACUTE"); - setOnset(today); - setNote(""); + setLabel(editEpisode?.label ?? ""); + setType(editEpisode?.type ?? "INFECTION"); + setLifecycle(editEpisode?.lifecycle ?? "ACUTE"); + setOnset(editEpisode ? editEpisode.onsetAt.slice(0, 10) : today); + setParentId(editEpisode?.parentConditionId ?? NONE); + setNote(editEpisode?.note ?? ""); } else if (!open && wasOpen) { setWasOpen(false); } + const showParent = PARENTABLE.includes(lifecycle); + async function handleSave() { if (label.trim() === "") return; + const onsetAt = new Date(`${onset}T12:00:00`).toISOString(); + const parentConditionId = showParent && parentId !== NONE ? parentId : null; try { - await create.mutateAsync({ - label: label.trim(), - type, - lifecycle, - // Anchor the date string to local noon so the UTC instant lands on - // the intended calendar day regardless of the viewer's offset. - onsetAt: new Date(`${onset}T12:00:00`).toISOString(), - note: note.trim() === "" ? null : note, - }); + if (isEdit && editEpisode) { + await update.mutateAsync({ + id: editEpisode.id, + input: { + label: label.trim(), + type, + lifecycle, + onsetAt, + parentConditionId, + note: note.trim() === "" ? null : note, + }, + }); + } else { + await create.mutateAsync({ + label: label.trim(), + type, + lifecycle, + // Anchor to local noon so the UTC instant lands on the intended + // calendar day regardless of the viewer's offset. + onsetAt, + parentConditionId, + note: note.trim() === "" ? null : note, + }); + } onOpenChange(false); } catch { // Keep the sheet open; the error strip reports it. @@ -98,17 +142,16 @@ export function NewEpisodeSheet({ - @@ -161,6 +204,25 @@ export function NewEpisodeSheet({
    + {showParent ? ( +
    + + +
    + ) : null} +
    - {create.isError ? ( + {isError ? (

    {t("illness.new.saveError")}

    diff --git a/src/components/illness/types.ts b/src/components/illness/types.ts index bbf59f652..04ee34c1a 100644 --- a/src/components/illness/types.ts +++ b/src/components/illness/types.ts @@ -52,6 +52,7 @@ export interface IllnessEpisodeCreateInput { type: IllnessType; lifecycle?: IllnessLifecycle; onsetAt?: string; + parentConditionId?: string | null; note?: string | null; } @@ -62,3 +63,79 @@ export interface IllnessDayLogInput { symptoms?: IllnessSymptomSelection[]; note?: string | null; } + +export interface IllnessEpisodeUpdateInput { + label?: string; + type?: IllnessType; + lifecycle?: IllnessLifecycle; + onsetAt?: string; + resolvedAt?: string | null; + parentConditionId?: string | null; + note?: string | null; +} + +/* ── P3 correlation + retrospective DTOs (server-authoritative) ───────── */ + +export interface IllnessVitalDeviation { + type: string; + day: string; + value: number; + baselineCenter: number; + deviationSd: number; + direction: "above" | "below"; + adverse: boolean; +} + +export interface IllnessVitalReturn { + type: string; + returnedDay: string | null; + gapDays: number | null; +} + +export interface IllnessRedFlag { + type: string; + reason: "sustained_low_spo2" | "sustained_fever"; + worstValue: number; + days: number; +} + +export interface IllnessCorrelationValue { + episodeId: string; + preOnset: IllnessVitalDeviation[]; + nadir: IllnessVitalDeviation[]; + returns: IllnessVitalReturn[]; + recoveryGapDays: number | null; + feltBetterDay: string | null; + redFlags: IllnessRedFlag[]; +} + +/** The flat `Derived` wire shape the correlation route returns. */ +export interface IllnessCorrelationResponse { + episodeId: string; + status: "ok" | "insufficient"; + value: IllnessCorrelationValue | null; + coverage: { + requiredInputs: number; + presentInputs: number; + historyDays: number; + missing: string[]; + }; + confidence: { score: number; band: string } | null; + provenance: { + inputs: string[]; + source: string; + windowDays: number; + computedAt: string; + }; + reason: string | null; +} + +export interface IllnessInsightsResponse { + windowDays: number; + episodeCount: number; + resolvedCount: number; + typicalRecoveryGapDays: number | null; + gapSampleSize: number; + byMonth: Record; + byType: Record; +} diff --git a/src/components/illness/use-illness.ts b/src/components/illness/use-illness.ts index 6cff6a0bb..613734720 100644 --- a/src/components/illness/use-illness.ts +++ b/src/components/illness/use-illness.ts @@ -14,10 +14,13 @@ import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch"; import { queryKeys } from "@/lib/query-keys"; import type { + IllnessCorrelationResponse, IllnessDayLogDTO, IllnessDayLogInput, IllnessEpisodeCreateInput, IllnessEpisodeDTO, + IllnessEpisodeUpdateInput, + IllnessInsightsResponse, } from "./types"; /** Newest-first episode list. */ @@ -31,6 +34,43 @@ export function useIllnessEpisodes(includeResolved = true) { }); } +/** One episode by id. */ +export function useIllnessEpisode(id: string | null) { + return useQuery({ + queryKey: queryKeys.illnessEpisode(id ?? "none"), + enabled: id !== null, + queryFn: () => apiGet(`/api/illness/episodes/${id}`), + }); +} + +/** + * The per-episode retrospective correlation (recovery-gap / red-flag / + * pre-onset / nadir). Server-authoritative + coverage-gated — the surface + * pattern-matches `status` ("insufficient" → "still learning"), never + * recomputes. + */ +export function useIllnessCorrelation(episodeId: string | null) { + return useQuery({ + queryKey: queryKeys.illnessCorrelation(episodeId ?? "none"), + enabled: episodeId !== null, + queryFn: () => + apiGet( + `/api/illness/episodes/${episodeId}/correlation`, + ), + }); +} + +/** The cross-episode retrospective summary over a trailing window in days. */ +export function useIllnessInsights(windowDays = 365) { + return useQuery({ + queryKey: queryKeys.illnessInsights(windowDays), + queryFn: () => + apiGet( + `/api/illness/insights?windowDays=${windowDays}`, + ), + }); +} + /** One episode's day-log for a given date (null when nothing is logged). */ export function useIllnessDayLog(episodeId: string | null, date: string) { return useQuery({ @@ -58,6 +98,16 @@ export function useCreateEpisode() { }); } +/** Edit an episode (partial). */ +export function useUpdateEpisode() { + const invalidate = useInvalidateIllness(); + return useMutation({ + mutationFn: ({ id, input }: { id: string; input: IllnessEpisodeUpdateInput }) => + apiPatch(`/api/illness/episodes/${id}`, input), + onSuccess: invalidate, + }); +} + /** Mark an episode recovered. */ export function useResolveEpisode() { const invalidate = useInvalidateIllness(); From 674e318ad7168def822f2365fcdb27e1f68769de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 16 Jun 2026 23:18:06 +0200 Subject: [PATCH 4/4] fix(illness): document the correlation + insights routes, cap the coach label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register the per-episode correlation and cross-episode insights GET routes in the OpenAPI source and regenerate the spec so the iOS contract carries the Derived findings shape. Cap and strip the free-text episode label before it enters the Coach prompt — it is the one user-controlled string in the illness context block, so collapse control and newline sequences and bound the length. --- docs/api/openapi.yaml | 382 +++++++++++++++++- .../__tests__/illness-snapshot-label.test.ts | 27 ++ src/lib/ai/coach/illness-snapshot.ts | 25 +- src/lib/openapi/routes/illness.ts | 173 +++++++- 4 files changed, 596 insertions(+), 11 deletions(-) create mode 100644 src/lib/ai/coach/__tests__/illness-snapshot-label.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index f994da49e..9adce7d5d 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -6375,8 +6375,8 @@ paths: tags: - Illness summary: Soft-delete an illness episode (v1.18.1) - description: Stamps `deletedAt` (tombstone). Idempotent — a re-delete is a no-op. Returns 204 No Content. Audits as - `illness.episode.delete`. Owner-scoped + born-gated. + description: "Stamps `deletedAt` (tombstone). Idempotent — a re-delete is a no-op. Returns the `{ deleted: true }` + envelope (the cross-module DELETE shape). Audits as `illness.episode.delete`. Owner-scoped + born-gated." parameters: - in: path name: id @@ -6384,8 +6384,12 @@ paths: type: string required: true responses: - "204": - description: Soft-deleted (no body). + "200": + description: Soft-deleted. + content: + application/json: + schema: + $ref: "#/components/schemas/DeleteIllnessEpisodeEnvelope" "401": *a1 "404": *a11 "422": *a3 @@ -6503,7 +6507,7 @@ paths: maxLength: 80 severity: type: integer - minimum: 0 + minimum: 1 maximum: 3 required: - key @@ -6535,6 +6539,59 @@ paths: "404": *a11 "422": *a3 "429": *a4 + /api/illness/episodes/{id}/correlation: + get: + tags: + - Illness + summary: Per-episode retrospective correlation (v1.18.1) + description: "Returns the coverage-gated `Derived` correlation for one episode: the pre-onset anomaly scan, the + nadir, per-vital physiological returns, the headline recovery-gap, and any red flags. The findings derive from + the user's OWN baseline (median ± MAD) over a contamination-guarded window — never a population constant. + `status:\"insufficient\"` carries coverage + a reason. Retrospective ONLY — never a predictor or diagnoser. + Owner-scoped + born-gated." + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: The correlation Derived DTO (ok or insufficient). + content: + application/json: + schema: + $ref: "#/components/schemas/IllnessCorrelationEnvelope" + "401": *a1 + "404": *a11 + "422": *a3 + "429": *a4 + /api/illness/insights: + get: + tags: + - Illness + summary: Cross-episode retrospective insights (v1.18.1) + description: "Returns the cross-episode retrospective summary over a trailing window: 'sick N times · typical recovery + gap X days', a recurrence-by-month tally, and a per-type breakdown. The typical gap is withheld (null) below the + min-sample floor (asserts nothing thin). Retrospective ONLY — the recurrence figure is a count of the past, + never a forecast. Born-gated + owner-scoped." + parameters: + - in: query + name: windowDays + schema: + type: integer + minimum: 30 + maximum: 1095 + responses: + "200": + description: The cross-episode retrospective summary. + content: + application/json: + schema: + $ref: "#/components/schemas/IllnessInsightsEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 components: schemas: PasskeyLoginVerifyRequest: @@ -18323,6 +18380,29 @@ components: - data - error additionalProperties: false + DeleteIllnessEpisodeEnvelope: + type: object + properties: + data: + type: object + properties: + deleted: + type: boolean + required: + - deleted + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false ResolveIllnessEpisodeEnvelope: type: object properties: @@ -18415,7 +18495,8 @@ components: - key - severity additionalProperties: false - description: "A symptom link on a day-log: the catalog `key` plus an optional 0–3 severity (`null` = a plain presence link)." + description: "A symptom link on a day-log: the catalog `key` plus an optional 1–3 graded severity (`null` = a plain + presence link; the link's presence already means 'present', so 0 is not a distinct state)." UpsertIllnessDayLogUpdatedEnvelope: type: object properties: @@ -18450,6 +18531,295 @@ components: - data - error additionalProperties: false + IllnessCorrelationEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/IllnessCorrelationResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + IllnessCorrelationResponse: + type: object + properties: + episodeId: + type: string + status: + type: string + enum: + - ok + - insufficient + value: + anyOf: + - $ref: "#/components/schemas/IllnessCorrelationValue" + - type: "null" + coverage: + type: object + properties: + requiredInputs: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + presentInputs: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + historyDays: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + missing: + type: array + items: + type: string + required: + - requiredInputs + - presentInputs + - historyDays + - missing + additionalProperties: false + confidence: + anyOf: + - type: object + properties: + score: + type: number + band: + type: string + required: + - score + - band + additionalProperties: false + - type: "null" + provenance: + type: object + properties: + inputs: + type: array + items: + type: string + source: + type: string + windowDays: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + computedAt: + type: string + required: + - inputs + - source + - windowDays + - computedAt + additionalProperties: false + reason: + anyOf: + - type: string + - type: "null" + required: + - episodeId + - status + - value + - coverage + - confidence + - provenance + - reason + additionalProperties: false + description: Coverage-gated `Derived` wire shape for the per-episode correlation. `status:"insufficient"` carries + coverage + a reason and a null `value` — the surface renders 'still learning', never a fabricated number. + Server-authoritative; iOS pattern-matches `status`, never recomputes. + IllnessCorrelationValue: + type: object + properties: + episodeId: + type: string + preOnset: + type: array + items: + $ref: "#/components/schemas/IllnessVitalDeviation" + nadir: + type: array + items: + $ref: "#/components/schemas/IllnessVitalDeviation" + returns: + type: array + items: + $ref: "#/components/schemas/IllnessVitalReturn" + recoveryGapDays: + anyOf: + - type: number + - type: "null" + feltBetterDay: + anyOf: + - type: string + - type: "null" + redFlags: + type: array + items: + $ref: "#/components/schemas/IllnessRedFlag" + required: + - episodeId + - preOnset + - nadir + - returns + - recoveryGapDays + - feltBetterDay + - redFlags + additionalProperties: false + description: "The retrospective correlation findings for one episode: pre-onset anomaly scan, nadir, per-vital + physiological returns, the headline recovery-gap (median of per-vital gaps), and any red flags." + IllnessVitalDeviation: + type: object + properties: + type: + type: string + day: + type: string + value: + type: number + baselineCenter: + type: number + deviationSd: + type: number + direction: + type: string + enum: + - above + - below + adverse: + type: boolean + required: + - type + - day + - value + - baselineCenter + - deviationSd + - direction + - adverse + additionalProperties: false + description: "One vital's deviation finding on a day: signed deviation in robust-SD units from the user's OWN baseline + (median ± MAD), the direction, and whether the move is illness-adverse for that metric." + IllnessVitalReturn: + type: object + properties: + type: + type: string + returnedDay: + anyOf: + - type: string + - type: "null" + gapDays: + anyOf: + - type: number + - type: "null" + required: + - type + - returnedDay + - gapDays + additionalProperties: false + description: "A vital's physiological return: the first day it re-entered its band AND held, and the signed gap (days) + from the felt-better marker (positive = the body lagged the feeling)." + IllnessRedFlag: + type: object + properties: + type: + type: string + reason: + type: string + enum: + - sustained_low_spo2 + - sustained_fever + worstValue: + type: number + days: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - type + - reason + - worstValue + - days + additionalProperties: false + description: A retrospective red-flag escalation (sustained low SpO2 or sustained fever) against absolute clinical + floors. Copy must escalate ('seek care if this recurs'), never reassure. + IllnessInsightsEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/IllnessInsightsResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + IllnessInsightsResponse: + type: object + properties: + windowDays: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + episodeCount: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + resolvedCount: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + typicalRecoveryGapDays: + anyOf: + - type: number + - type: "null" + gapSampleSize: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + byMonth: + type: object + propertyNames: + type: string + additionalProperties: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + byType: + type: object + propertyNames: + type: string + additionalProperties: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - windowDays + - episodeCount + - resolvedCount + - typicalRecoveryGapDays + - gapSampleSize + - byMonth + - byType + additionalProperties: false + description: "Cross-episode retrospective summary over the trailing window: episode + resolved counts, the typical + (median) recovery gap (null below the min-sample floor — withholds a thin claim), a recurrence-by-month tally, + and a per-type breakdown. Retrospective only." MedicationScheduleOutput: type: object properties: diff --git a/src/lib/ai/coach/__tests__/illness-snapshot-label.test.ts b/src/lib/ai/coach/__tests__/illness-snapshot-label.test.ts new file mode 100644 index 000000000..f911e6cd6 --- /dev/null +++ b/src/lib/ai/coach/__tests__/illness-snapshot-label.test.ts @@ -0,0 +1,27 @@ +/** + * v1.18.1 — the free-text episode label that reaches the Coach LLM prompt is + * the one user-controlled string in the illness snapshot block, so it is a + * (self-scoped) prompt-injection surface. `sanitizeLabel` caps the length and + * strips control / newline sequences so an embedded instruction cannot reshape + * the prompt structure. + */ +import { describe, expect, it } from "vitest"; +import { sanitizeLabel } from "@/lib/ai/coach/illness-snapshot"; + +describe("sanitizeLabel", () => { + it("collapses newlines + control chars into spaces", () => { + const out = sanitizeLabel("Cold\n\nIgnore previous instructions\tnow"); + expect(out).not.toContain("\n"); + expect(out).not.toContain("\t"); + expect(out).toBe("Cold Ignore previous instructions now"); + }); + + it("caps the length to 80 chars", () => { + const out = sanitizeLabel("a".repeat(200)); + expect(out.length).toBe(80); + }); + + it("trims and preserves a normal label (incl. umlauts)", () => { + expect(sanitizeLabel(" Grippe (Nürnberg) ")).toBe("Grippe (Nürnberg)"); + }); +}); diff --git a/src/lib/ai/coach/illness-snapshot.ts b/src/lib/ai/coach/illness-snapshot.ts index 12f948752..e9a9c4ad0 100644 --- a/src/lib/ai/coach/illness-snapshot.ts +++ b/src/lib/ai/coach/illness-snapshot.ts @@ -19,6 +19,27 @@ import { isIllnessEnabled } from "@/lib/illness/gate"; /** How many recently-resolved episodes to include as history context. */ const RESOLVED_HISTORY_LIMIT = 6; +/** Max characters of the user free-text label that may enter the prompt. */ +const MAX_LABEL_CHARS = 80; + +/** + * Cap + strip the user-supplied free-text episode label before it enters the + * Coach LLM prompt. The label is the one user-controlled string in this block, + * so it is a (self-scoped) prompt-injection surface: collapse control chars + + * newlines to spaces so an embedded "ignore previous instructions\n..." can't + * reshape the prompt structure, and bound the length. + */ +export function sanitizeLabel(label: string): string { + // Replace every C0/C1 control char (incl. newlines) with a space, collapse + // runs of whitespace, trim, then bound the length. + let out = ""; + for (const ch of label) { + const code = ch.codePointAt(0) ?? 0; + out += code < 0x20 || (code >= 0x7f && code <= 0x9f) ? " " : ch; + } + return out.replace(/\s+/g, " ").trim().slice(0, MAX_LABEL_CHARS); +} + export interface CoachIllnessBlock { /** True when ≥ 1 episode is active right now (the Rest Mode flag). */ restMode: boolean; @@ -68,13 +89,13 @@ export async function buildIllnessSnapshotBlock( return { restMode: activeRows.length > 0, active: activeRows.map((e) => ({ - label: e.label, + label: sanitizeLabel(e.label), type: e.type, lifecycle: e.lifecycle, onsetAt: e.onsetAt.toISOString(), })), recentResolved: resolvedRows.map((e) => ({ - label: e.label, + label: sanitizeLabel(e.label), type: e.type, onsetAt: e.onsetAt.toISOString(), // `resolvedAt` is non-null by the query filter above. diff --git a/src/lib/openapi/routes/illness.ts b/src/lib/openapi/routes/illness.ts index ef28e76ca..784e2dd6b 100644 --- a/src/lib/openapi/routes/illness.ts +++ b/src/lib/openapi/routes/illness.ts @@ -23,6 +23,7 @@ import { illnessEpisodeListQuerySchema, illnessDayLogInputSchema, illnessDayLogQuerySchema, + illnessInsightsQuerySchema, illnessTypeEnum, illnessLifecycleEnum, } from "@/lib/validations/illness"; @@ -65,6 +66,12 @@ illnessDayLogQuerySchema.meta({ "Single-day read query: `date` is a `YYYY-MM-DD` day. Returns the matching day-log or `null` when nothing is logged for that day.", }); +illnessInsightsQuerySchema.meta({ + id: "IllnessInsightsQuery", + description: + "Cross-episode retrospective window query: optional `windowDays` (30–1095, default 365). Retrospective only — the engine summarises past episodes, never forecasts.", +}); + const illnessSymptom = z .object({ key: z.string(), @@ -73,7 +80,7 @@ const illnessSymptom = z .meta({ id: "IllnessSymptom", description: - "A symptom link on a day-log: the catalog `key` plus an optional 0–3 severity (`null` = a plain presence link).", + "A symptom link on a day-log: the catalog `key` plus an optional 1–3 graded severity (`null` = a plain presence link; the link's presence already means 'present', so 0 is not a distinct state).", }); const illnessEpisode = z @@ -112,6 +119,109 @@ const illnessDayLog = z "A stored day-log for an episode. `date` is the `YYYY-MM-DD` it covers. `functionalImpact` (0–3) and `feverC` are plaintext; `symptoms` flattens the link rows; `note` is the decrypted free-text (or null).", }); +/* ── P3 retrospective correlation + cross-episode insights DTOs ───────── */ + +const illnessVitalDeviation = z + .object({ + type: z.string(), + day: z.string(), + value: z.number(), + baselineCenter: z.number(), + deviationSd: z.number(), + direction: z.enum(["above", "below"]), + adverse: z.boolean(), + }) + .meta({ + id: "IllnessVitalDeviation", + description: + "One vital's deviation finding on a day: signed deviation in robust-SD units from the user's OWN baseline (median ± MAD), the direction, and whether the move is illness-adverse for that metric.", + }); + +const illnessVitalReturn = z + .object({ + type: z.string(), + returnedDay: z.string().nullable(), + gapDays: z.number().nullable(), + }) + .meta({ + id: "IllnessVitalReturn", + description: + "A vital's physiological return: the first day it re-entered its band AND held, and the signed gap (days) from the felt-better marker (positive = the body lagged the feeling).", + }); + +const illnessRedFlag = z + .object({ + type: z.string(), + reason: z.enum(["sustained_low_spo2", "sustained_fever"]), + worstValue: z.number(), + days: z.number().int(), + }) + .meta({ + id: "IllnessRedFlag", + description: + "A retrospective red-flag escalation (sustained low SpO2 or sustained fever) against absolute clinical floors. Copy must escalate ('seek care if this recurs'), never reassure.", + }); + +const illnessCorrelationValue = z + .object({ + episodeId: z.string(), + preOnset: z.array(illnessVitalDeviation), + nadir: z.array(illnessVitalDeviation), + returns: z.array(illnessVitalReturn), + recoveryGapDays: z.number().nullable(), + feltBetterDay: z.string().nullable(), + redFlags: z.array(illnessRedFlag), + }) + .meta({ + id: "IllnessCorrelationValue", + description: + "The retrospective correlation findings for one episode: pre-onset anomaly scan, nadir, per-vital physiological returns, the headline recovery-gap (median of per-vital gaps), and any red flags.", + }); + +const illnessCorrelationResponse = z + .object({ + episodeId: z.string(), + status: z.enum(["ok", "insufficient"]), + value: illnessCorrelationValue.nullable(), + coverage: z.object({ + requiredInputs: z.number().int(), + presentInputs: z.number().int(), + historyDays: z.number().int(), + missing: z.array(z.string()), + }), + confidence: z + .object({ score: z.number(), band: z.string() }) + .nullable(), + provenance: z.object({ + inputs: z.array(z.string()), + source: z.string(), + windowDays: z.number().int(), + computedAt: z.string(), + }), + reason: z.string().nullable(), + }) + .meta({ + id: "IllnessCorrelationResponse", + description: + "Coverage-gated `Derived` wire shape for the per-episode correlation. `status:\"insufficient\"` carries coverage + a reason and a null `value` — the surface renders 'still learning', never a fabricated number. Server-authoritative; iOS pattern-matches `status`, never recomputes.", + }); + +const illnessInsightsResponse = z + .object({ + windowDays: z.number().int(), + episodeCount: z.number().int(), + resolvedCount: z.number().int(), + typicalRecoveryGapDays: z.number().nullable(), + gapSampleSize: z.number().int(), + byMonth: z.record(z.string(), z.number().int()), + byType: z.record(z.string(), z.number().int()), + }) + .meta({ + id: "IllnessInsightsResponse", + description: + "Cross-episode retrospective summary over the trailing window: episode + resolved counts, the typical (median) recovery gap (null below the min-sample floor — withholds a thin claim), a recurrence-by-month tally, and a per-type breakdown. Retrospective only.", + }); + const episodeNotFound = { "404": { description: "Illness episode not found (or owned by another user).", @@ -221,10 +331,20 @@ export const illnessPaths: NonNullable = { tags: ["Illness"], summary: "Soft-delete an illness episode (v1.18.1)", description: - "Stamps `deletedAt` (tombstone). Idempotent — a re-delete is a no-op. Returns 204 No Content. Audits as `illness.episode.delete`. Owner-scoped + born-gated.", + "Stamps `deletedAt` (tombstone). Idempotent — a re-delete is a no-op. Returns the `{ deleted: true }` envelope (the cross-module DELETE shape). Audits as `illness.episode.delete`. Owner-scoped + born-gated.", requestParams: { path: z.object({ id: z.string() }) }, responses: { - "204": { description: "Soft-deleted (no body)." }, + "200": { + description: "Soft-deleted.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ deleted: z.boolean() }), + "DeleteIllnessEpisodeEnvelope", + ), + }, + }, + }, ...episodeNotFound, ...stdResponses, }, @@ -326,4 +446,51 @@ export const illnessPaths: NonNullable = { }, }, }, + "/api/illness/episodes/{id}/correlation": { + get: { + tags: ["Illness"], + summary: "Per-episode retrospective correlation (v1.18.1)", + description: + "Returns the coverage-gated `Derived` correlation for one episode: the pre-onset anomaly scan, the nadir, per-vital physiological returns, the headline recovery-gap, and any red flags. The findings derive from the user's OWN baseline (median ± MAD) over a contamination-guarded window — never a population constant. `status:\"insufficient\"` carries coverage + a reason. Retrospective ONLY — never a predictor or diagnoser. Owner-scoped + born-gated.", + requestParams: { path: z.object({ id: z.string() }) }, + responses: { + "200": { + description: "The correlation Derived DTO (ok or insufficient).", + content: { + "application/json": { + schema: dataEnvelope( + illnessCorrelationResponse, + "IllnessCorrelationEnvelope", + ), + }, + }, + }, + ...episodeNotFound, + ...stdResponses, + }, + }, + }, + "/api/illness/insights": { + get: { + tags: ["Illness"], + summary: "Cross-episode retrospective insights (v1.18.1)", + description: + "Returns the cross-episode retrospective summary over a trailing window: 'sick N times · typical recovery gap X days', a recurrence-by-month tally, and a per-type breakdown. The typical gap is withheld (null) below the min-sample floor (asserts nothing thin). Retrospective ONLY — the recurrence figure is a count of the past, never a forecast. Born-gated + owner-scoped.", + requestParams: { query: illnessInsightsQuerySchema }, + responses: { + "200": { + description: "The cross-episode retrospective summary.", + content: { + "application/json": { + schema: dataEnvelope( + illnessInsightsResponse, + "IllnessInsightsEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, };