diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index f424cf858..14d3a2fe2 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -20279,6 +20279,10 @@ components: anyOf: - type: number - type: "null" + adverseCoverageDays: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 feltBetterDay: anyOf: - type: string @@ -20293,6 +20297,7 @@ components: - nadir - returns - recoveryGapDays + - adverseCoverageDays - feltBetterDay - redFlags additionalProperties: false @@ -20429,6 +20434,10 @@ components: type: integer minimum: -9007199254740991 maximum: 9007199254740991 + gapDriverType: + anyOf: + - type: string + - type: "null" required: - windowDays - episodeCount @@ -20437,10 +20446,12 @@ components: - gapSampleSize - byMonth - byType + - gapDriverType 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." + (median) recovery gap (null below the min-sample floor — withholds a thin claim), a recurrence-by-month tally, a + per-type breakdown, and the dominant driving vital (`gapDriverType`, null when no gap is surfaced). + Retrospective only." DisclaimerAckEnvelope: type: object properties: diff --git a/messages/de.json b/messages/de.json index 6674ca67f..c15520593 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6880,6 +6880,7 @@ "countOne": "Du hast in diesem Zeitraum 1 Episode erfasst.", "countOther": "Du hast in diesem Zeitraum {count} Episoden erfasst.", "typicalGap": "Typische Erholungslücke: {days} Tage zwischen Besserung und normalisierten Werten.", + "typicalGapWithDriver": "Typische Erholungslücke: {days} Tage – du fühlst dich meist besser, bevor sich {vital} normalisiert.", "typicalGapLearning": "Noch ein paar abgeschlossene Episoden und hier erscheint eine typische Erholungslücke.", "none": "In diesem Zeitraum keine Episoden erfasst.", "analyze": "Erholungslücke analysieren" diff --git a/messages/en.json b/messages/en.json index 2f753c94a..4ad161b92 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6880,6 +6880,7 @@ "countOne": "You logged 1 episode in this period.", "countOther": "You logged {count} episodes in this period.", "typicalGap": "Typical recovery gap: {days} days between feeling better and your numbers settling.", + "typicalGapWithDriver": "Typical recovery gap: {days} days — you tend to feel better before your {vital} settles.", "typicalGapLearning": "A few more resolved episodes and a typical recovery gap will appear here.", "none": "No episodes logged in this period.", "analyze": "Analyse the recovery gap" diff --git a/messages/es.json b/messages/es.json index ff16bdc8f..66d9d2421 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6880,6 +6880,7 @@ "countOne": "Registraste 1 episodio en este período.", "countOther": "Registraste {count} episodios en este período.", "typicalGap": "Brecha de recuperación típica: {days} días entre sentirte mejor y la estabilización de tus valores.", + "typicalGapWithDriver": "Brecha de recuperación típica: {days} días: sueles sentirte mejor antes de que {vital} se estabilice.", "typicalGapLearning": "Con unos pocos episodios resueltos más aparecerá aquí una brecha de recuperación típica.", "none": "Sin episodios registrados en este período.", "analyze": "Analizar la brecha de recuperación" diff --git a/messages/fr.json b/messages/fr.json index a3c2c476c..608aef1a6 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6880,6 +6880,7 @@ "countOne": "Tu as enregistré 1 épisode sur cette période.", "countOther": "Tu as enregistré {count} épisodes sur cette période.", "typicalGap": "Écart de récupération typique : {days} jours entre le mieux-être et la stabilisation de tes valeurs.", + "typicalGapWithDriver": "Écart de récupération typique : {days} jours – tu te sens généralement mieux avant que {vital} ne se stabilise.", "typicalGapLearning": "Encore quelques épisodes résolus et un écart de récupération typique apparaîtra ici.", "none": "Aucun épisode enregistré sur cette période.", "analyze": "Analyser l'écart de récupération" diff --git a/messages/it.json b/messages/it.json index 3e512e59b..226eaebb0 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6880,6 +6880,7 @@ "countOne": "Hai registrato 1 episodio in questo periodo.", "countOther": "Hai registrato {count} episodi in questo periodo.", "typicalGap": "Divario di recupero tipico: {days} giorni tra il sentirti meglio e la stabilizzazione dei valori.", + "typicalGapWithDriver": "Divario di recupero tipico: {days} giorni: di solito ti senti meglio prima che {vital} si stabilizzi.", "typicalGapLearning": "Ancora qualche episodio risolto e qui apparirà un divario di recupero tipico.", "none": "Nessun episodio registrato in questo periodo.", "analyze": "Analizza il divario di recupero" diff --git a/messages/pl.json b/messages/pl.json index 9709e45af..4853b2972 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6880,6 +6880,7 @@ "countOne": "W tym okresie zapisałeś 1 epizod.", "countOther": "W tym okresie zapisałeś {count} epizodów.", "typicalGap": "Typowa luka rekonwalescencji: {days} dni między poprawą a ustabilizowaniem wartości.", + "typicalGapWithDriver": "Typowa luka rekonwalescencji: {days} dni – zwykle czujesz się lepiej, zanim {vital} się ustabilizuje.", "typicalGapLearning": "Jeszcze kilka zakończonych epizodów i pojawi się tu typowa luka rekonwalescencji.", "none": "Brak epizodów zapisanych w tym okresie.", "analyze": "Przeanalizuj lukę rekonwalescencji" diff --git a/src/app/api/illness/insights/route.ts b/src/app/api/illness/insights/route.ts index 1e47c0482..0e31fb6d3 100644 --- a/src/app/api/illness/insights/route.ts +++ b/src/app/api/illness/insights/route.ts @@ -85,7 +85,11 @@ export const GET = apiHandler(async (request: NextRequest) => { // out of the typical-gap median by the aggregate's signal-density gates. const gapById = new Map< string, - { recoveryGapDays: number | null; gapMeasurementDays: number } + { + recoveryGapDays: number | null; + gapMeasurementDays: number; + gapReturnTypes: string[]; + } >(); if (includeRecoveryGap) { const gapCandidates = rows @@ -98,7 +102,11 @@ export const GET = apiHandler(async (request: NextRequest) => { async (): Promise< [ string, - { recoveryGapDays: number | null; gapMeasurementDays: number }, + { + recoveryGapDays: number | null; + gapMeasurementDays: number; + gapReturnTypes: string[]; + }, ] > => { const derived = await computeEpisodeCorrelation( @@ -119,9 +127,26 @@ export const GET = apiHandler(async (request: NextRequest) => { derived.status === "ok" ? derived.value.recoveryGapDays : null, - // `historyDays` = distinct episode days with a banded vital, - // present on both the `ok` and `insufficient` arms. - gapMeasurementDays: derived.coverage.historyDays, + // Qualifying-days floor: distinct episode days carrying an + // ADVERSE-direction banded reading — NOT raw coverage + // (`historyDays` counts ANY banded vital, so a WEIGHT-only + // episode with no illness-adverse signal could otherwise clear + // the floor and tip the typical-gap median). Only the `ok` arm + // ever contributes a gap; the `insufficient` arm's gap is null, + // so 0 here is correct (it never reaches the median anyway). + gapMeasurementDays: + derived.status === "ok" + ? derived.value.adverseCoverageDays + : 0, + // The vitals that produced a real physiological return — + // tallied across the qualifying episodes to name the dominant + // driving vital in the summary copy. + gapReturnTypes: + derived.status === "ok" + ? derived.value.returns + .filter((r) => r.gapDays !== null) + .map((r) => String(r.type)) + : [], }, ]; }, @@ -140,6 +165,7 @@ export const GET = apiHandler(async (request: NextRequest) => { resolved: r.resolvedAt !== null, recoveryGapDays: gap?.recoveryGapDays ?? null, gapMeasurementDays: gap?.gapMeasurementDays ?? 0, + gapReturnTypes: gap?.gapReturnTypes ?? [], lifecycle: r.lifecycle, }; }); @@ -152,6 +178,7 @@ export const GET = apiHandler(async (request: NextRequest) => { episode_count: summary.episodeCount, gap_sample_size: summary.gapSampleSize, typical_gap_days: summary.typicalRecoveryGapDays, + gap_driver_type: summary.gapDriverType, include_recovery_gap: includeRecoveryGap, }, }); diff --git a/src/components/illness/illness-insights-card.tsx b/src/components/illness/illness-insights-card.tsx index 15ea9f02f..86beede7a 100644 --- a/src/components/illness/illness-insights-card.tsx +++ b/src/components/illness/illness-insights-card.tsx @@ -87,13 +87,22 @@ export function IllnessInsightsCard({

{t("illness.insights.typicalGapLearning")}

+ ) : gap.data.typicalRecoveryGapDays === null ? ( +

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

+ ) : gap.data.gapDriverType ? ( +

+ {t("illness.insights.typicalGapWithDriver", { + days: gap.data.typicalRecoveryGapDays, + vital: t(`illness.vital.${gap.data.gapDriverType}`), + })} +

) : (

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

)} diff --git a/src/components/illness/types.ts b/src/components/illness/types.ts index 6526f22e4..6bda26316 100644 --- a/src/components/illness/types.ts +++ b/src/components/illness/types.ts @@ -115,6 +115,7 @@ export interface IllnessCorrelationValue { nadir: IllnessVitalDeviation[]; returns: IllnessVitalReturn[]; recoveryGapDays: number | null; + adverseCoverageDays: number; feltBetterDay: string | null; redFlags: IllnessRedFlag[]; } @@ -148,4 +149,5 @@ export interface IllnessInsightsResponse { gapSampleSize: number; byMonth: Record; byType: Record; + gapDriverType: string | null; } diff --git a/src/lib/illness/__tests__/correlation.test.ts b/src/lib/illness/__tests__/correlation.test.ts index f9941c81d..e3366fe99 100644 --- a/src/lib/illness/__tests__/correlation.test.ts +++ b/src/lib/illness/__tests__/correlation.test.ts @@ -16,7 +16,9 @@ import type { MeasurementType } from "@/generated/prisma/client"; import { computeIllnessCorrelation, dayDiff, + isAdverseDeviation, MIN_BASELINE_DAYS, + MIN_EPISODE_COVERAGE_DAYS, type IllnessCorrelationInput, type VitalDayPoint, } from "../correlation"; @@ -431,6 +433,102 @@ describe("computeIllnessCorrelation — red-flag escalation", () => { }); }); +describe("isAdverseDeviation", () => { + it("pins per-metric adverse direction (up for RHR, down for HRV)", () => { + expect(isAdverseDeviation("RESTING_HEART_RATE", "above")).toBe(true); + expect(isAdverseDeviation("RESTING_HEART_RATE", "below")).toBe(false); + expect(isAdverseDeviation("HEART_RATE_VARIABILITY", "below")).toBe(true); + expect(isAdverseDeviation("HEART_RATE_VARIABILITY", "above")).toBe(false); + }); + + it("treats neutral-direction vitals (WEIGHT) as never adverse", () => { + expect(isAdverseDeviation("WEIGHT", "above")).toBe(false); + expect(isAdverseDeviation("WEIGHT", "below")).toBe(false); + }); +}); + +describe("computeIllnessCorrelation — adverse-coverage qualifying floor", () => { + it("counts only days with a notable ADVERSE-direction reading", () => { + // RHR (up = adverse) spikes out-of-band on 3 distinct active days, then + // settles. Those 3 days — and only those — count toward the floor. + const type: MeasurementType = "RESTING_HEART_RATE"; + const baselineDays = jitteredBaseline(55, 1, 21, "2026-01-02"); + const episodeDays: VitalDayPoint[] = [ + { day: "2026-01-10", mean: 75 }, // adverse spike + { day: "2026-01-11", mean: 74 }, // adverse spike + { day: "2026-01-12", mean: 73 }, // adverse spike + { day: "2026-01-15", mean: 55 }, // in band + { day: "2026-01-16", mean: 55 }, + { day: "2026-01-17", mean: 55 }, + ]; + const out = computeIllnessCorrelation( + input({ series: [{ type, baselineDays, episodeDays }] }), + ); + expect(out.status).toBe("ok"); + if (out.status !== "ok") return; + expect(out.value.adverseCoverageDays).toBe(3); + }); + + it("does NOT count a WEIGHT-only (no adverse direction) episode toward the floor", () => { + // WEIGHT has no illness-adverse direction. Even with plenty of out-of-band + // WEIGHT days (so raw coverage `historyDays` clears the engine floor), the + // adverse-coverage floor stays 0 — a WEIGHT-only episode must not feed the + // cross-episode typical-gap median. + const type: MeasurementType = "WEIGHT"; + const baselineDays = jitteredBaseline(80, 1, 21, "2026-01-02"); + const episodeDays: VitalDayPoint[] = [ + { day: "2026-01-10", mean: 90 }, // far out of band, but neutral direction + { day: "2026-01-11", mean: 91 }, + { day: "2026-01-12", mean: 92 }, + { day: "2026-01-13", mean: 70 }, // out of band the other way — still neutral + { day: "2026-01-14", mean: 69 }, + ]; + const out = computeIllnessCorrelation( + input({ series: [{ type, baselineDays, episodeDays }] }), + ); + // Coverage clears the engine floor (5 banded days) so the engine returns ok… + expect(out.status).toBe("ok"); + if (out.status !== "ok") return; + expect(out.coverage.historyDays).toBeGreaterThanOrEqual( + MIN_EPISODE_COVERAGE_DAYS, + ); + // …but NO day counts toward the adverse-coverage qualifying floor. + expect(out.value.adverseCoverageDays).toBe(0); + }); + + it("ignores a non-adverse-direction move on an adverse-typed vital", () => { + // RHR is up-adverse. A big DROP (below band) is notable but NOT adverse, so + // it must not count toward the floor. Pair with a banded neutral vital to + // clear the engine coverage floor without adding adverse days. + const rhr: MeasurementType = "RESTING_HEART_RATE"; + const weight: MeasurementType = "WEIGHT"; + const out = computeIllnessCorrelation( + input({ + series: [ + { + type: rhr, + baselineDays: jitteredBaseline(55, 1, 21, "2026-01-02"), + episodeDays: [ + { day: "2026-01-10", mean: 40 }, // far BELOW band — not adverse for RHR + { day: "2026-01-11", mean: 41 }, + { day: "2026-01-12", mean: 55 }, + { day: "2026-01-13", mean: 55 }, + ], + }, + { + type: weight, + baselineDays: jitteredBaseline(80, 1, 21, "2026-01-02"), + episodeDays: flatBaseline(80, 5, "2026-01-15"), + }, + ], + }), + ); + expect(out.status).toBe("ok"); + if (out.status !== "ok") return; + expect(out.value.adverseCoverageDays).toBe(0); + }); +}); + 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 diff --git a/src/lib/illness/__tests__/retrospective.test.ts b/src/lib/illness/__tests__/retrospective.test.ts index 878072f52..f9dc38125 100644 --- a/src/lib/illness/__tests__/retrospective.test.ts +++ b/src/lib/illness/__tests__/retrospective.test.ts @@ -131,6 +131,74 @@ describe("summarizeIllnessRetrospective", () => { expect(out.typicalRecoveryGapDays).toBeNull(); // …but the magnitude is noise }); + it("names the dominant driving vital when a gap is surfaced", () => { + const out = summarizeIllnessRetrospective([ + ep({ + id: "a", + recoveryGapDays: 4, + gapReturnTypes: ["RESTING_HEART_RATE"], + }), + ep({ + id: "b", + recoveryGapDays: 6, + gapReturnTypes: ["RESTING_HEART_RATE", "HEART_RATE_VARIABILITY"], + }), + ep({ + id: "c", + recoveryGapDays: 5, + gapReturnTypes: ["HEART_RATE_VARIABILITY"], + }), + ]); + // RHR returned in 2 of 3 episodes, HRV in 2 as well — tie → deterministic + // alphabetical resolution picks HEART_RATE_VARIABILITY. + expect(out.typicalRecoveryGapDays).toBe(5); + expect(out.gapDriverType).toBe("HEART_RATE_VARIABILITY"); + }); + + it("picks the strictly-most-frequent driver", () => { + const out = summarizeIllnessRetrospective([ + ep({ + id: "a", + recoveryGapDays: 4, + gapReturnTypes: ["RESTING_HEART_RATE"], + }), + ep({ + id: "b", + recoveryGapDays: 6, + gapReturnTypes: ["RESTING_HEART_RATE"], + }), + ep({ + id: "c", + recoveryGapDays: 5, + gapReturnTypes: ["HEART_RATE_VARIABILITY"], + }), + ]); + expect(out.gapDriverType).toBe("RESTING_HEART_RATE"); + }); + + it("leaves the driver null when no gap is surfaced", () => { + const out = summarizeIllnessRetrospective([ + ep({ + id: "a", + recoveryGapDays: 0, + gapReturnTypes: ["RESTING_HEART_RATE"], + }), + ep({ + id: "b", + recoveryGapDays: 1, + gapReturnTypes: ["RESTING_HEART_RATE"], + }), + ep({ + id: "c", + recoveryGapDays: -1, + gapReturnTypes: ["RESTING_HEART_RATE"], + }), + ]); + // Magnitude gate withholds the gap → no driver is named. + expect(out.typicalRecoveryGapDays).toBeNull(); + expect(out.gapDriverType).toBeNull(); + }); + it("surfaces a negative gap once its magnitude clears the floor", () => { const out = summarizeIllnessRetrospective([ ep({ id: "a", recoveryGapDays: -2 }), diff --git a/src/lib/illness/correlation.ts b/src/lib/illness/correlation.ts index 22f519670..ae8676a10 100644 --- a/src/lib/illness/correlation.ts +++ b/src/lib/illness/correlation.ts @@ -208,6 +208,24 @@ export interface VitalReturnFinding { gapDays: number | null; } +/** + * Whether a banded reading is the illness-adverse move for its metric — a + * RISE for an "up"-adverse vital (resting HR, temperature, BP, …), a DROP for + * a "down"-adverse one (HRV, recovery, SpO2). The single adverse-direction + * predicate the engine reasons with; reused by the qualifying-days floor so a + * neutral-direction vital (WEIGHT, glucose) never counts a day toward it. + */ +export function isAdverseDeviation( + type: MeasurementType, + direction: "above" | "below", +): boolean { + const adverseDir = ADVERSE_DIRECTION[type]; + return ( + (adverseDir === "up" && direction === "above") || + (adverseDir === "down" && direction === "below") + ); +} + export interface IllnessCorrelationValue { episodeId: string; /** "How did it announce itself" — notable pre-onset deviations. */ @@ -222,6 +240,15 @@ export interface IllnessCorrelationValue { * = the body lagged the felt-better marker. */ recoveryGapDays: number | null; + /** + * Distinct episode days carrying at least one ADVERSE-direction banded + * reading (a vital moving the illness-adverse way for its metric). This is + * the qualifying-days floor the cross-episode aggregate gates the typical- + * gap median on — a WEIGHT-only (no adverse direction) episode contributes + * 0 here even when `coverage.historyDays` (any banded reading) is high, so + * an episode with no illness-relevant signal cannot tip the typical gap. + */ + adverseCoverageDays: number; /** Felt-better marker echoed for the surface. */ feltBetterDay: string | null; /** @@ -279,10 +306,7 @@ function finding( ): VitalDeviationFinding { const sd = deviationSd(value, center, spread); const direction: "above" | "below" = sd >= 0 ? "above" : "below"; - const adverseDir = ADVERSE_DIRECTION[type]; - const adverse = - (adverseDir === "up" && direction === "above") || - (adverseDir === "down" && direction === "below"); + const adverse = isAdverseDeviation(type, direction); return { type, day, @@ -364,6 +388,11 @@ export function computeIllnessCorrelation( const preOnset: VitalDeviationFinding[] = []; const nadir: VitalDeviationFinding[] = []; const returns: VitalReturnFinding[] = []; + // Distinct active-span days with at least one notable ADVERSE-direction + // banded reading. This — not raw `coverageDays` (any banded reading) — is + // the qualifying-days floor the cross-episode aggregate gates on, so a + // neutral-direction-only episode (e.g. WEIGHT) never feeds the typical gap. + const adverseDayKeys = new Set(); for (const { series, band } of banded) { const { type } = series; @@ -396,6 +425,7 @@ export function computeIllnessCorrelation( const f = finding(type, p.day, p.mean, center, spread); if (Math.abs(f.deviationSd) >= NOTABLE_SD) { if (firstDeviationIndex === -1) firstDeviationIndex = i; + if (f.adverse) adverseDayKeys.add(p.day); if ( worstActive === null || Math.abs(f.deviationSd) > Math.abs(worstActive.deviationSd) @@ -463,6 +493,7 @@ export function computeIllnessCorrelation( ), returns, recoveryGapDays, + adverseCoverageDays: adverseDayKeys.size, feltBetterDay: window.feltBetterDay, redFlags, }, diff --git a/src/lib/illness/retrospective.ts b/src/lib/illness/retrospective.ts index ff93e92b0..8e01c45c4 100644 --- a/src/lib/illness/retrospective.ts +++ b/src/lib/illness/retrospective.ts @@ -62,6 +62,14 @@ export interface RetrospectiveEpisode { * the engine computed one. Absent/0 → does not qualify. */ gapMeasurementDays?: number; + /** + * The vital metric types that produced a non-null physiological-return gap + * for this episode (the engine's `returns[]` filtered to a real return). + * Tallied across the qualifying episodes to name the dominant driving vital + * in the cross-episode summary ("felt better ~X days before resting heart + * rate settled"). Absent/empty → contributes no driver. + */ + gapReturnTypes?: string[]; /** Lifecycle — CHRONIC_ONGOING never contributes a gap. */ lifecycle: string; } @@ -87,6 +95,13 @@ export interface IllnessRetrospectiveSummary { byMonth: Record; /** Per-type episode counts in the window (retrospective breakdown). */ byType: Record; + /** + * The dominant vital that drove the typical recovery gap — the metric type + * that most often produced a physiological return across the qualifying + * episodes — or null when no gap is surfaced or no vital clearly leads. The + * card names it ("…before resting heart rate settled"). Retrospective only. + */ + gapDriverType: string | null; } /** @@ -109,6 +124,10 @@ export function summarizeIllnessRetrospective( const byType: Record = {}; let resolvedCount = 0; const gaps: number[] = []; + // Tally of which vital produced a physiological return, across only the + // episodes that qualified for the typical-gap median — the dominant one + // names the driving vital in the card. + const driverTally: Record = {}; for (const e of episodes) { const month = Number(e.onsetDay.slice(5, 7)); @@ -124,6 +143,8 @@ export function summarizeIllnessRetrospective( (e.gapMeasurementDays ?? 0) >= MIN_GAP_MEASUREMENT_DAYS ) { gaps.push(e.recoveryGapDays); + for (const type of e.gapReturnTypes ?? []) + driverTally[type] = (driverTally[type] ?? 0) + 1; } } @@ -139,6 +160,16 @@ export function summarizeIllnessRetrospective( ? medianGap : null; + // Name the driving vital only when a gap is actually surfaced — the most + // frequently-returning vital across the qualifying episodes. Ties resolve + // deterministically by metric name so the copy is stable across reads. + const gapDriverType = + typicalRecoveryGapDays !== null + ? (Object.entries(driverTally).sort((a, b) => + b[1] !== a[1] ? b[1] - a[1] : a[0] < b[0] ? -1 : 1, + )[0]?.[0] ?? null) + : null; + return { episodeCount: episodes.length, resolvedCount, @@ -146,5 +177,6 @@ export function summarizeIllnessRetrospective( gapSampleSize: gaps.length, byMonth, byType, + gapDriverType, }; } diff --git a/src/lib/openapi/routes/illness.ts b/src/lib/openapi/routes/illness.ts index 8759a005b..900f49119 100644 --- a/src/lib/openapi/routes/illness.ts +++ b/src/lib/openapi/routes/illness.ts @@ -208,6 +208,7 @@ const illnessCorrelationValue = z nadir: z.array(illnessVitalDeviation), returns: z.array(illnessVitalReturn), recoveryGapDays: z.number().nullable(), + adverseCoverageDays: z.number().int(), feltBetterDay: z.string().nullable(), redFlags: z.array(illnessRedFlag), }) @@ -252,11 +253,12 @@ const illnessInsightsResponse = z gapSampleSize: z.number().int(), byMonth: z.record(z.string(), z.number().int()), byType: z.record(z.string(), z.number().int()), + gapDriverType: z.string().nullable(), }) .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.", + "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, a per-type breakdown, and the dominant driving vital (`gapDriverType`, null when no gap is surfaced). Retrospective only.", }); const episodeNotFound = {