From fc76c39aeae6fd0e0d489bf62cd1fde8f167ebd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 21 Jun 2026 21:06:06 +0200 Subject: [PATCH 1/2] feat(illness): fold the logged symptom curve into the recovery gap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The recovery-gap engine reasoned only over passive vitals and ignored the journal's own per-day symptom series. Add the functional-impact (symptom-burden) return track: read functionalImpact per episode day (falling back to the strongest linked symptom severity) and treat it as one more return against the known-constant baseline (healthy = 0). The symptom track needs none of the MAD-band / contamination machinery the passive vitals carry — the band is the constant 0 — so it is more reliable to fold in than any passive vital. It is adverse by construction, so it contributes to adverseCoverageDays and the headline gap median, giving the qualifying-days floor a genuinely illness-relevant contributor where a weight-only episode had none. Stability is logged-days-only: a sparse journal that logs impact-0 once and stops withholds the return rather than fabricating an all-clear from the absence of further logs. gapDriverType names the dominant return track, with the symptom curve winning ties as the most illness-specific signal. --- src/lib/illness/__tests__/correlation.test.ts | 195 ++++++++++++++++++ src/lib/illness/correlation-read.ts | 58 +++++- src/lib/illness/correlation.ts | 180 +++++++++++++++- src/lib/openapi/routes/illness.ts | 6 +- 4 files changed, 435 insertions(+), 4 deletions(-) diff --git a/src/lib/illness/__tests__/correlation.test.ts b/src/lib/illness/__tests__/correlation.test.ts index 27bc955b3..fd5a855b3 100644 --- a/src/lib/illness/__tests__/correlation.test.ts +++ b/src/lib/illness/__tests__/correlation.test.ts @@ -16,10 +16,12 @@ import type { MeasurementType } from "@/generated/prisma/client"; import { computeIllnessCorrelation, dayDiff, + FUNCTIONAL_IMPACT_RETURN_KEY, isAdverseDeviation, MIN_BASELINE_DAYS, MIN_EPISODE_COVERAGE_DAYS, type IllnessCorrelationInput, + type SymptomBurdenPoint, type VitalDayPoint, } from "../correlation"; @@ -615,3 +617,196 @@ describe("computeIllnessCorrelation — return anchor", () => { expect(out.value.recoveryGapDays).toBeGreaterThan(0); }); }); + +describe("computeIllnessCorrelation — symptom-burden return track", () => { + // A banded RHR vital present in EVERY case below clears the engine's + // episode-coverage floor (the gate counts banded-vital days, not symptom + // days). Its band stays IN throughout so it produces no adverse signal and + // never competes with the symptom track for the driver — it is scaffolding. + const rhr: MeasurementType = "RESTING_HEART_RATE"; + const rhrBaseline = jitteredBaseline(55, 1, 21, "2026-01-02"); + const rhrFlat: VitalDayPoint[] = [ + { day: "2026-01-10", mean: 55 }, + { day: "2026-01-11", mean: 55 }, + { day: "2026-01-12", mean: 55 }, + { day: "2026-01-13", mean: 55 }, + { day: "2026-01-14", mean: 55 }, + ]; + + it("folds a functional-impact return into the gap with a constant-0 baseline", () => { + // Onset 01-10, felt better 01-14. Logged impact: bedbound early, eases to 0 + // on 01-16 and HOLDS for 3 logged days → symptom return 01-16, gap +2. + const symptomBurden: SymptomBurdenPoint[] = [ + { day: "2026-01-10", impact: 3 }, + { day: "2026-01-12", impact: 2 }, + { day: "2026-01-14", impact: 1 }, // felt-better day, still symptomatic + { day: "2026-01-16", impact: 0 }, // return start + { day: "2026-01-17", impact: 0 }, + { day: "2026-01-18", impact: 0 }, // 3 logged in-band days → stable + ]; + const out = computeIllnessCorrelation( + input({ + window: { + onsetDay: "2026-01-10", + feltBetterDay: "2026-01-14", + lifecycle: "ACUTE", + }, + series: [ + { type: rhr, baselineDays: rhrBaseline, episodeDays: rhrFlat }, + ], + symptomBurden, + }), + ); + expect(out.status).toBe("ok"); + if (out.status !== "ok") return; + const sym = out.value.returns.find( + (r) => r.type === FUNCTIONAL_IMPACT_RETURN_KEY, + ); + expect(sym).toBeDefined(); + expect(sym?.returnedDay).toBe("2026-01-16"); + expect(sym?.adverse).toBe(true); + // Gap = 01-16 − 01-14 = +2. RHR stayed flat (no gap), so the symptom track + // is the only contributor → headline gap = +2. + expect(sym?.gapDays).toBe(dayDiff("2026-01-14", "2026-01-16")); + expect(out.value.recoveryGapDays).toBe(2); + // It contributes to the adverse-coverage floor (3 logged adverse days). + expect(out.value.adverseCoverageDays).toBeGreaterThanOrEqual(3); + expect(out.value.gapDriverType).toBe(FUNCTIONAL_IMPACT_RETURN_KEY); + }); + + it("WITHHOLDS the symptom return when logging is too sparse to stabilise", () => { + // The user logs while sick, then logs impact-0 ONCE and stops — the + // 3-logged-day stability run can never fire. The symptom track must + // contribute NO gap (honest withholding), never fabricate recovery from the + // absence of further logs. + const symptomBurden: SymptomBurdenPoint[] = [ + { day: "2026-01-10", impact: 3 }, + { day: "2026-01-12", impact: 2 }, + { day: "2026-01-16", impact: 0 }, // single trailing in-band log + ]; + const out = computeIllnessCorrelation( + input({ + window: { + onsetDay: "2026-01-10", + feltBetterDay: "2026-01-14", + lifecycle: "ACUTE", + }, + series: [ + { type: rhr, baselineDays: rhrBaseline, episodeDays: rhrFlat }, + ], + symptomBurden, + }), + ); + expect(out.status).toBe("ok"); + if (out.status !== "ok") return; + const sym = out.value.returns.find( + (r) => r.type === FUNCTIONAL_IMPACT_RETURN_KEY, + ); + expect(sym).toBeDefined(); + expect(sym?.returnedDay).toBeNull(); + expect(sym?.gapDays).toBeNull(); + // No vital returned either → no headline gap fabricated. + expect(out.value.recoveryGapDays).toBeNull(); + expect(out.value.gapDriverType).toBeNull(); + // The logged adverse days still count toward coverage (honest). + expect(out.value.adverseCoverageDays).toBeGreaterThanOrEqual(2); + }); + + it("lets the symptom track win the driver over a co-returning vital", () => { + // Both the symptom curve AND an adverse RHR spike return and produce gaps; + // the functional-impact track must win `gapDriverType` on a tie (it is the + // most illness-specific signal). + const rhrSpike: VitalDayPoint[] = [ + { day: "2026-01-10", mean: 75 }, // adverse up + { day: "2026-01-11", mean: 74 }, + { day: "2026-01-16", mean: 55 }, // back in band + { day: "2026-01-17", mean: 55 }, + { day: "2026-01-18", mean: 55 }, // 3 stable + ]; + const symptomBurden: SymptomBurdenPoint[] = [ + { day: "2026-01-10", impact: 3 }, + { day: "2026-01-12", impact: 2 }, + { day: "2026-01-16", impact: 0 }, // same return day → same gap as RHR + { day: "2026-01-17", impact: 0 }, + { day: "2026-01-18", impact: 0 }, + ]; + const out = computeIllnessCorrelation( + input({ + window: { + onsetDay: "2026-01-10", + feltBetterDay: "2026-01-14", + lifecycle: "ACUTE", + }, + series: [ + { type: rhr, baselineDays: rhrBaseline, episodeDays: rhrSpike }, + ], + symptomBurden, + }), + ); + expect(out.status).toBe("ok"); + if (out.status !== "ok") return; + const sym = out.value.returns.find( + (r) => r.type === FUNCTIONAL_IMPACT_RETURN_KEY, + ); + const rhrReturn = out.value.returns.find((r) => r.type === rhr); + expect(sym?.gapDays).toBe(2); + expect(rhrReturn?.gapDays).toBe(2); + expect(out.value.recoveryGapDays).toBe(2); + // Tie on |gap − median| → functional-impact wins. + expect(out.value.gapDriverType).toBe(FUNCTIONAL_IMPACT_RETURN_KEY); + }); + + it("ignores a symptom curve with no adverse day (nothing to return from)", () => { + const symptomBurden: SymptomBurdenPoint[] = [ + { day: "2026-01-10", impact: 0 }, + { day: "2026-01-12", impact: 0 }, + ]; + const out = computeIllnessCorrelation( + input({ + window: { + onsetDay: "2026-01-10", + feltBetterDay: "2026-01-14", + lifecycle: "ACUTE", + }, + series: [ + { type: rhr, baselineDays: rhrBaseline, episodeDays: rhrFlat }, + ], + symptomBurden, + }), + ); + expect(out.status).toBe("ok"); + if (out.status !== "ok") return; + expect( + out.value.returns.find((r) => r.type === FUNCTIONAL_IMPACT_RETURN_KEY), + ).toBeUndefined(); + }); + + it("never produces a symptom return for CHRONIC_ONGOING", () => { + const symptomBurden: SymptomBurdenPoint[] = [ + { day: "2026-01-10", impact: 3 }, + { day: "2026-01-16", impact: 0 }, + { day: "2026-01-17", impact: 0 }, + { day: "2026-01-18", impact: 0 }, + ]; + const out = computeIllnessCorrelation( + input({ + window: { + onsetDay: "2026-01-10", + feltBetterDay: "2026-01-14", + lifecycle: "CHRONIC_ONGOING", + }, + series: [ + { type: rhr, baselineDays: rhrBaseline, episodeDays: rhrFlat }, + ], + symptomBurden, + }), + ); + expect(out.status).toBe("ok"); + if (out.status !== "ok") return; + expect(out.value.recoveryGapDays).toBeNull(); + expect(out.value.gapDriverType).toBeNull(); + expect( + out.value.returns.find((r) => r.type === FUNCTIONAL_IMPACT_RETURN_KEY), + ).toBeUndefined(); + }); +}); diff --git a/src/lib/illness/correlation-read.ts b/src/lib/illness/correlation-read.ts index b9e1c0dfd..ca05de559 100644 --- a/src/lib/illness/correlation-read.ts +++ b/src/lib/illness/correlation-read.ts @@ -39,6 +39,7 @@ import { computeIllnessCorrelation, type DayLogFeverPoint, type IllnessCorrelationValue, + type SymptomBurdenPoint, type VitalSeries, } from "./correlation"; @@ -103,7 +104,7 @@ export async function computeEpisodeCorrelation( // run paired rather than serially (halving the per-type round-trips). // The day-log fever read shares the same fan-out budget. const limit = pLimit(VITAL_SCAN_CONCURRENCY); - const [perType, dayLogFever] = await Promise.all([ + const [perType, dayLogFever, symptomBurden] = await Promise.all([ Promise.all( ILLNESS_SCAN_TYPES.map((type) => limit(async () => { @@ -132,6 +133,7 @@ export async function computeEpisodeCorrelation( ), ), limit(() => readDayLogFever(episode.id, preOnsetStart, end, tz)), + limit(() => readDayLogSymptomBurden(episode.id, preOnsetStart, end, tz)), ]); const series: VitalSeries[] = []; @@ -167,6 +169,7 @@ export async function computeEpisodeCorrelation( window: { onsetDay, feltBetterDay, lifecycle: episode.lifecycle }, series, dayLogFever, + symptomBurden, source, now, }); @@ -275,3 +278,56 @@ async function readDayLogFever( .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) .map(([day, feverC]) => ({ day, feverC })); } + +/** + * Read the illness day-log symptom-burden series over [from, to) — the user's + * own logged daily-impact curve, keyed by the stored `date` (already the + * user-tz day string, so NO tz conversion). Mirrors `readDayLogFever`: one + * `findMany`, present days only, no zero-fill. + * + * Per day the burden is `functionalImpact` (0–3, the deliberate daily slider) + * when logged; else the day's MAX linked `IllnessSymptomLink.severity` (0–3) as + * a fallback corroborator. A day with neither is dropped (absent from the + * series — the engine treats a missing day as "not logged", never an + * implicit 0). + */ +async function readDayLogSymptomBurden( + 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, + date: { gte: fromKey, lte: toKey }, + }, + select: { + date: true, + functionalImpact: true, + symptomLinks: { select: { severity: true } }, + }, + }); + const points: SymptomBurdenPoint[] = []; + for (const row of rows) { + if (row.functionalImpact != null) { + points.push({ day: row.date, impact: row.functionalImpact }); + continue; + } + // Fallback corroborator: the day's strongest linked symptom severity. + let maxSeverity: number | null = null; + for (const link of row.symptomLinks) { + if (link.severity == null) continue; + maxSeverity = + maxSeverity === null + ? link.severity + : Math.max(maxSeverity, link.severity); + } + if (maxSeverity != null) + points.push({ day: row.date, impact: maxSeverity }); + } + return points.sort((a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : 0)); +} diff --git a/src/lib/illness/correlation.ts b/src/lib/illness/correlation.ts index e220b0f14..7756c7c33 100644 --- a/src/lib/illness/correlation.ts +++ b/src/lib/illness/correlation.ts @@ -66,6 +66,21 @@ export const NOTABLE_SD = 2; * for the recovery-gap (one lucky in-band day is not a recovery). */ export const RETURN_STABILITY_DAYS = 3; +/** + * Sentinel `type` for the functional-impact return track. The symptom curve is + * NOT a `MeasurementType` — it is the user's own logged daily-impact slider — + * so it rides `returns[]` under a stable string key the surface keys copy off + * (the typed `VitalReturnFinding.type` is widened to accept it). Tallied like + * any vital type in `gapReturnTypes` so it can win `gapDriverType`. + */ +export const FUNCTIONAL_IMPACT_RETURN_KEY = "FUNCTIONAL_IMPACT" as const; +/** + * Functional-impact threshold (0–3 slider) at/above which a logged day counts + * as ADVERSE (symptomatic). The baseline is the known constant 0 (healthy), so + * the symptom curve needs none of the MAD-band / contamination machinery the + * passive vitals carry — "return" is simply impact back to below this floor. + */ +export const FUNCTIONAL_IMPACT_ADVERSE_FLOOR = 1; /** Minimum episode days with ANY vital coverage before the gap is asserted. */ export const MIN_EPISODE_COVERAGE_DAYS = 4; @@ -153,6 +168,22 @@ export interface DayLogFeverPoint { feverC: number; } +/** + * A user-logged symptom-burden reading from the illness day-log, keyed by the + * stored `date` (already the user-tz local day). `impact` is the day's + * `functionalImpact` slider (0–3, 0 = fully functional … 3 = bedbound); when + * NULL it is the day's max linked `IllnessSymptomLink.severity` (0–3) as a + * fallback corroborator, or null when neither was logged. Present days only — + * never zero-filled (mirrors the fever read), so sparse logging WITHHOLDS the + * symptom return rather than fabricating an all-clear from absence of rows. + */ +export interface SymptomBurdenPoint { + /** Local day key, `YYYY-MM-DD`. */ + day: string; + /** The day's symptom burden (0–3): functionalImpact, else max severity. */ + impact: number; +} + /** The dates the engine reasons over, all `YYYY-MM-DD` local day keys. */ export interface EpisodeWindow { /** Onset day. */ @@ -174,6 +205,17 @@ export interface IllnessCorrelationInput { * escalation even with no passive BODY_TEMPERATURE rows. Optional. */ dayLogFever?: DayLogFeverPoint[]; + /** + * Per-day symptom-burden readings from the illness day-log (`functionalImpact` + * primary, max linked symptom `severity` as fallback). Folded into the + * recovery-gap as ONE more return track against the known-constant baseline + * (healthy = 0): a "symptom return" is the burden back below the adverse floor + * and held for `RETURN_STABILITY_DAYS` LOGGED days. Always `adverse:true` (it + * is illness-relevant by construction), so it contributes to + * `adverseCoverageDays` and can drive the headline gap even on a + * passive-vital-thin episode. Optional. + */ + symptomBurden?: SymptomBurdenPoint[]; /** Provenance source the dominant read resolved against. */ source: "DAY" | "live" | "none"; /** Compute time (injected for deterministic tests). */ @@ -201,7 +243,11 @@ export interface VitalDeviationFinding { /** A vital's recovery timing within the episode. */ export interface VitalReturnFinding { - type: MeasurementType; + /** + * The metric the return describes — a `MeasurementType` for a passive vital, + * or `FUNCTIONAL_IMPACT_RETURN_KEY` for the user-logged symptom-burden track. + */ + type: MeasurementType | typeof FUNCTIONAL_IMPACT_RETURN_KEY; /** First day the vital re-entered its band AND stayed in for the window. */ returnedDay: string | null; /** Days from felt-better to physiological return (signed); null if N/A. */ @@ -255,6 +301,16 @@ export interface IllnessCorrelationValue { adverseCoverageDays: number; /** Felt-better marker echoed for the surface. */ feltBetterDay: string | null; + /** + * The metric whose physiological return dominates the headline gap — the + * adverse return-track whose own gap is closest to the median, with the + * functional-impact symptom track winning ties (it is the most + * illness-specific signal). A `MeasurementType`, + * `FUNCTIONAL_IMPACT_RETURN_KEY`, or null when no adverse return drove a gap. + * The card names it ("…before your symptoms eased" vs "…before your resting + * heart rate settled"). Retrospective only. + */ + gapDriverType: string | null; /** * Red-flag escalation — a sustained adverse SpO2 drop or sustained fever * during the episode. Retrospective, but the copy must escalate ("if this @@ -471,6 +527,21 @@ export function computeIllnessCorrelation( } } + // Symptom-burden return track — the user's OWN logged daily-impact curve, + // folded in as one more return against the KNOWN-CONSTANT baseline (healthy + // = 0). No MAD band, no contamination guard, no min-baseline floor: the band + // IS the constant 0, so it sidesteps every reliability caveat the passive + // vitals carry. Always adverse by construction (a logged impact is illness- + // relevant), so it contributes to `adverseCoverageDays` and the headline gap + // even when the passive vitals are thin — the genuine illness-relevant + // contributor the WEIGHT-only floor lacked. Logged-days-only stability: + // sparse logging WITHHOLDS the return rather than fabricating an all-clear. + const symptomReturn = computeSymptomReturn(input.symptomBurden ?? [], window); + if (symptomReturn) { + returns.push(symptomReturn.finding); + for (const day of symptomReturn.adverseDays) adverseDayKeys.add(day); + } + // 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 @@ -489,6 +560,31 @@ export function computeIllnessCorrelation( ? null : Math.round(median(gaps)); + // Name the per-episode driver: among the ADVERSE return tracks that produced + // a real gap, the one whose own gap sits closest to the headline median (it + // is the return that "explains" the headline). The functional-impact symptom + // track wins ties — it is the most illness-specific signal the engine has, so + // when it co-explains the gap it gets to name it ("…before your symptoms + // eased"). Null when no adverse return drove a gap. + const gapDriverType = + recoveryGapDays === null + ? null + : (returns + .filter( + (r): r is VitalReturnFinding & { gapDays: number } => + r.gapDays !== null && r.adverse, + ) + .sort((a, b) => { + const da = Math.abs(a.gapDays - recoveryGapDays); + const db = Math.abs(b.gapDays - recoveryGapDays); + if (da !== db) return da - db; + // Tie: prefer the functional-impact symptom track, then by name. + const fa = a.type === FUNCTIONAL_IMPACT_RETURN_KEY ? 0 : 1; + const fb = b.type === FUNCTIONAL_IMPACT_RETURN_KEY ? 0 : 1; + if (fa !== fb) return fa - fb; + return String(a.type) < String(b.type) ? -1 : 1; + })[0]?.type ?? null); + const { coverage, confidence } = deriveCoverage({ requiredInputs: 1, presentInputs: banded.length, @@ -510,6 +606,7 @@ export function computeIllnessCorrelation( recoveryGapDays, adverseCoverageDays: adverseDayKeys.size, feltBetterDay: window.feltBetterDay, + gapDriverType: gapDriverType === null ? null : String(gapDriverType), redFlags, }, coverage, @@ -544,6 +641,87 @@ function firstStableReturn( return null; } +/** + * Compute the functional-impact (symptom-burden) return track for one episode. + * + * The symptom curve's baseline is the KNOWN CONSTANT 0 (healthy), so this needs + * none of the passive vitals' MAD-band / contamination machinery — "adverse" is + * simply `impact >= FUNCTIONAL_IMPACT_ADVERSE_FLOOR`, "in band" is below it. + * + * - Only LOGGED days (present in `burden`) participate — never zero-filled. + * - The anchor is the first logged adverse day (same role as the vital + * `firstDeviationIndex`): a return search starts AFTER it, so a run-up is + * never read as a return. + * - A "return" is the first logged day at/after the anchor that is in-band AND + * stays in-band for the next `RETURN_STABILITY_DAYS` LOGGED days. LOGGED-days + * stability (not calendar days) is the honest-withholding handler for sparse + * journals: a single trailing impact-0 log never satisfies the run, so the + * track WITHHOLDS rather than fabricating recovery from absence of rows. + * - The gap is `dayDiff(feltBetterDay, returnedDay)`, same signed semantic. + * - Always `adverse:true` (a logged symptom curve is illness-relevant), so it + * feeds `adverseCoverageDays` and can drive the headline gap. + * + * Returns null (contributes nothing) when no adverse day was logged or the + * episode is CHRONIC_ONGOING. `adverseDays` is the set of logged adverse days in + * the active span, for the coverage tally. + */ +function computeSymptomReturn( + burden: SymptomBurdenPoint[], + window: EpisodeWindow, +): { finding: VitalReturnFinding; adverseDays: string[] } | null { + if (window.lifecycle === "CHRONIC_ONGOING") return null; + // Active span only, chronological. (Logged days are sparse and arbitrary, so + // a stable sort by the day key is the canonical order.) + const active = burden + .filter((p) => p.day >= window.onsetDay) + .sort((a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : 0)); + if (active.length === 0) return null; + + const adverseDays = active + .filter((p) => p.impact >= FUNCTIONAL_IMPACT_ADVERSE_FLOOR) + .map((p) => p.day); + // No adverse day logged → there is nothing to "return" from. + if (adverseDays.length === 0) return null; + + // Anchor: first logged adverse day. The return search starts there so an + // early in-band log (impact 0 before symptoms appeared) is never a return. + const firstAdverseIndex = active.findIndex( + (p) => p.impact >= FUNCTIONAL_IMPACT_ADVERSE_FLOOR, + ); + let returnedDay: string | null = null; + for (let i = firstAdverseIndex; i < active.length; i++) { + if (active[i].impact >= FUNCTIONAL_IMPACT_ADVERSE_FLOOR) continue; + // Count consecutive in-band LOGGED days from here. + let run = 0; + for ( + let j = i; + j < active.length && active[j].impact < FUNCTIONAL_IMPACT_ADVERSE_FLOOR; + j++ + ) { + run++; + } + if (run >= RETURN_STABILITY_DAYS) { + returnedDay = active[i].day; + break; + } + } + + const gapDays = + returnedDay && window.feltBetterDay + ? dayDiff(window.feltBetterDay, returnedDay) + : null; + + return { + finding: { + type: FUNCTIONAL_IMPACT_RETURN_KEY, + returnedDay, + gapDays, + adverse: true, + }, + adverseDays, + }; +} + /** * Detect the retrospective red flags — DECOUPLED from the banded loop and the * own-baseline band entirely. SpO2 and temperature escalate against ABSOLUTE diff --git a/src/lib/openapi/routes/illness.ts b/src/lib/openapi/routes/illness.ts index 900f49119..4b8131e8a 100644 --- a/src/lib/openapi/routes/illness.ts +++ b/src/lib/openapi/routes/illness.ts @@ -181,11 +181,12 @@ const illnessVitalReturn = z type: z.string(), returnedDay: z.string().nullable(), gapDays: z.number().nullable(), + adverse: z.boolean(), }) .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).", + "A physiological / symptom return: the first day the metric re-entered its band AND held, the signed gap (days) from the felt-better marker (positive = the body lagged the feeling), and whether the move was illness-adverse (only adverse returns name the driver). `type` is a MeasurementType or `FUNCTIONAL_IMPACT` for the user-logged symptom-burden track (functionalImpact, baseline = constant 0).", }); const illnessRedFlag = z @@ -210,12 +211,13 @@ const illnessCorrelationValue = z recoveryGapDays: z.number().nullable(), adverseCoverageDays: z.number().int(), feltBetterDay: z.string().nullable(), + gapDriverType: 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.", + "The retrospective correlation findings for one episode: pre-onset anomaly scan, nadir, per-vital physiological returns (incl. the user-logged symptom-burden track, `type:FUNCTIONAL_IMPACT`), the headline recovery-gap (median of per-track gaps), the driving metric (`gapDriverType`), and any red flags.", }); const illnessCorrelationResponse = z From 1352ba2aa65eda447d92fd437df4e48edca50130 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 21 Jun 2026 21:06:14 +0200 Subject: [PATCH 2/2] feat(illness): name the symptom curve in the recovery-gap card When the logged symptom curve drives the recovery gap, the card names it ('you felt better N days before your logged symptoms eased') instead of the vital-driven phrasing. Mirror the new gapDriverType and the return track's adverse flag into the client DTO, and add the driver-aware copy across all six locales. --- messages/de.json | 2 ++ messages/en.json | 2 ++ messages/es.json | 2 ++ messages/fr.json | 2 ++ messages/it.json | 2 ++ messages/pl.json | 2 ++ .../illness/illness-correlation-card.tsx | 20 +++++++++++++++---- src/components/illness/types.ts | 8 ++++++++ 8 files changed, 36 insertions(+), 4 deletions(-) diff --git a/messages/de.json b/messages/de.json index c15520593..91889f5ad 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6866,6 +6866,8 @@ "recoveryGapTitle": "Erholungslücke", "recoveryGapLag": "Deine Werte normalisierten sich {days} Tage, nachdem es dir besser ging.", "recoveryGapLead": "Deine Werte normalisierten sich {days} Tage, bevor du Genesung vermerktest.", + "recoveryGapLagSymptom": "Dir ging es {days} Tage besser, bevor deine erfassten Symptome abklangen.", + "recoveryGapLeadSymptom": "Deine erfassten Symptome klangen {days} Tage ab, bevor du Genesung vermerktest.", "recoveryGapSame": "Deine Werte normalisierten sich am selben Tag, an dem es dir besser ging.", "redFlagTitle": "Genauer ansehen", "redFlagSpo2": "Deine Sauerstoffsättigung blieb mehrere Tage niedrig. Wenn das erneut auftritt, suche ärztliche Hilfe.", diff --git a/messages/en.json b/messages/en.json index 4ad161b92..32cac8164 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6866,6 +6866,8 @@ "recoveryGapTitle": "Recovery gap", "recoveryGapLag": "Your numbers settled {days} days after you felt better.", "recoveryGapLead": "Your numbers settled {days} days before you marked recovery.", + "recoveryGapLagSymptom": "You felt better {days} days before your logged symptoms eased.", + "recoveryGapLeadSymptom": "Your logged symptoms eased {days} days before you marked recovery.", "recoveryGapSame": "Your numbers settled the same day you felt better.", "redFlagTitle": "Worth a closer look", "redFlagSpo2": "Your oxygen saturation stayed low for several days. If this recurs, seek medical care.", diff --git a/messages/es.json b/messages/es.json index 66d9d2421..ba7e9e577 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6866,6 +6866,8 @@ "recoveryGapTitle": "Brecha de recuperación", "recoveryGapLag": "Tus valores se estabilizaron {days} días después de sentirte mejor.", "recoveryGapLead": "Tus valores se estabilizaron {days} días antes de marcar la recuperación.", + "recoveryGapLagSymptom": "Te sentiste mejor {days} días antes de que cedieran tus síntomas registrados.", + "recoveryGapLeadSymptom": "Tus síntomas registrados cedieron {days} días antes de marcar la recuperación.", "recoveryGapSame": "Tus valores se estabilizaron el mismo día en que te sentiste mejor.", "redFlagTitle": "Conviene revisarlo", "redFlagSpo2": "Tu saturación de oxígeno se mantuvo baja varios días. Si se repite, busca atención médica.", diff --git a/messages/fr.json b/messages/fr.json index 608aef1a6..b90b9f7e2 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6866,6 +6866,8 @@ "recoveryGapTitle": "Écart de récupération", "recoveryGapLag": "Tes valeurs se sont stabilisées {days} jours après que tu te sois senti mieux.", "recoveryGapLead": "Tes valeurs se sont stabilisées {days} jours avant que tu notes la guérison.", + "recoveryGapLagSymptom": "Tu t'es senti mieux {days} jours avant que tes symptômes consignés s'apaisent.", + "recoveryGapLeadSymptom": "Tes symptômes consignés se sont apaisés {days} jours avant que tu notes la guérison.", "recoveryGapSame": "Tes valeurs se sont stabilisées le jour même où tu t'es senti mieux.", "redFlagTitle": "À examiner de plus près", "redFlagSpo2": "Ta saturation en oxygène est restée basse plusieurs jours. Si cela se reproduit, consulte un médecin.", diff --git a/messages/it.json b/messages/it.json index 226eaebb0..9f3c112f3 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6866,6 +6866,8 @@ "recoveryGapTitle": "Divario di recupero", "recoveryGapLag": "I tuoi valori si sono stabilizzati {days} giorni dopo esserti sentito meglio.", "recoveryGapLead": "I tuoi valori si sono stabilizzati {days} giorni prima di segnare la guarigione.", + "recoveryGapLagSymptom": "Ti sei sentito meglio {days} giorni prima che i sintomi registrati si attenuassero.", + "recoveryGapLeadSymptom": "I tuoi sintomi registrati si sono attenuati {days} giorni prima di segnare la guarigione.", "recoveryGapSame": "I tuoi valori si sono stabilizzati lo stesso giorno in cui ti sei sentito meglio.", "redFlagTitle": "Da guardare più da vicino", "redFlagSpo2": "La tua saturazione di ossigeno è rimasta bassa per diversi giorni. Se si ripete, rivolgiti a un medico.", diff --git a/messages/pl.json b/messages/pl.json index 4853b2972..2704ab145 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6866,6 +6866,8 @@ "recoveryGapTitle": "Luka rekonwalescencji", "recoveryGapLag": "Twoje wartości ustabilizowały się {days} dni po tym, jak poczułeś się lepiej.", "recoveryGapLead": "Twoje wartości ustabilizowały się {days} dni przed oznaczeniem wyzdrowienia.", + "recoveryGapLagSymptom": "Poczułeś się lepiej {days} dni przed ustąpieniem zapisanych objawów.", + "recoveryGapLeadSymptom": "Twoje zapisane objawy ustąpiły {days} dni przed oznaczeniem wyzdrowienia.", "recoveryGapSame": "Twoje wartości ustabilizowały się tego samego dnia, gdy poczułeś się lepiej.", "redFlagTitle": "Warto przyjrzeć się bliżej", "redFlagSpo2": "Twoje wysycenie tlenem pozostawało niskie przez kilka dni. Jeśli się powtórzy, zasięgnij pomocy medycznej.", diff --git a/src/components/illness/illness-correlation-card.tsx b/src/components/illness/illness-correlation-card.tsx index 000bac25f..57e937154 100644 --- a/src/components/illness/illness-correlation-card.tsx +++ b/src/components/illness/illness-correlation-card.tsx @@ -44,6 +44,10 @@ function DeviationRow({ d }: { d: IllnessVitalDeviation }) { function CorrelationBody({ value }: { value: IllnessCorrelationValue }) { const { t } = useTranslations(); const gap = value.recoveryGapDays; + // Driver-aware copy: when the user's logged symptom curve drove the gap, name + // the symptoms ("…before your symptoms eased"); otherwise the existing + // vital-driven phrasing. Clinically calm — observation, never prediction. + const symptomDriven = value.gapDriverType === "FUNCTIONAL_IMPACT"; return (
@@ -71,11 +75,19 @@ function CorrelationBody({ value }: { value: IllnessCorrelationValue }) {

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

diff --git a/src/components/illness/types.ts b/src/components/illness/types.ts index 6bda26316..581dcd3fd 100644 --- a/src/components/illness/types.ts +++ b/src/components/illness/types.ts @@ -97,9 +97,11 @@ export interface IllnessVitalDeviation { } export interface IllnessVitalReturn { + /** A `MeasurementType`, or `"FUNCTIONAL_IMPACT"` for the symptom-burden track. */ type: string; returnedDay: string | null; gapDays: number | null; + adverse: boolean; } export interface IllnessRedFlag { @@ -117,6 +119,12 @@ export interface IllnessCorrelationValue { recoveryGapDays: number | null; adverseCoverageDays: number; feltBetterDay: string | null; + /** + * The metric that drove the headline gap — a `MeasurementType`, + * `"FUNCTIONAL_IMPACT"` (the symptom-burden track), or null. Drives the + * driver-aware recovery-gap copy. + */ + gapDriverType: string | null; redFlags: IllnessRedFlag[]; }