From 36affd5dafbcec3d1f326adaab07f264637135fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 21 Jun 2026 14:07:44 +0200 Subject: [PATCH 1/2] fix(illness): gate the recovery-gap floor on adverse-direction days MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cross-episode typical-gap median counts an episode toward its qualifying-days floor via the per-episode coverage `historyDays`, which counts a day if ANY banded vital has a reading. A WEIGHT-only episode (no illness-adverse direction) could therefore clear the floor and feed the median despite carrying no illness-relevant signal, weakening the honest- sample claim. Compute a distinct-adverse-day count in the engine — days with at least one notable reading in the metric's adverse direction — and feed that as the qualifying-days floor instead. Reuses the engine's single adverse- direction predicate (now exported as `isAdverseDeviation`); a neutral- direction vital never counts a day. The insufficient arm contributes 0, which is correct since its gap is null and never reaches the median. --- docs/api/openapi.yaml | 5 + src/app/api/illness/insights/route.ts | 14 ++- src/components/illness/types.ts | 1 + src/lib/illness/__tests__/correlation.test.ts | 98 +++++++++++++++++++ src/lib/illness/correlation.ts | 39 +++++++- src/lib/openapi/routes/illness.ts | 1 + 6 files changed, 151 insertions(+), 7 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index f424cf858..f5cc0e154 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 diff --git a/src/app/api/illness/insights/route.ts b/src/app/api/illness/insights/route.ts index 1e47c0482..f11ec4bab 100644 --- a/src/app/api/illness/insights/route.ts +++ b/src/app/api/illness/insights/route.ts @@ -119,9 +119,17 @@ 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, }, ]; }, diff --git a/src/components/illness/types.ts b/src/components/illness/types.ts index 6526f22e4..ec3563e1b 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[]; } 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/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/openapi/routes/illness.ts b/src/lib/openapi/routes/illness.ts index 8759a005b..2c8bc9571 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), }) From 638bc6c8352fd48e567de17ed16a616ab1d79cc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 21 Jun 2026 14:11:20 +0200 Subject: [PATCH 2/2] feat(illness): name the driving vital in the recovery-gap summary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cross-episode summary already computes per-vital physiological returns but the card surfaced only one bare line. Thread the dominant returning vital through the aggregate and the DTO so the copy reads 'you tend to feel better before your resting heart rate settles' instead of a context-free gap figure. The aggregate tallies which vital produced a real return across the episodes that qualified for the typical-gap median and reports the most frequent one (deterministic alphabetical tiebreak); the driver is named only when a gap actually clears the magnitude gate. Zero new compute — the returns were already in hand. Adds the localized copy in all six locales. --- docs/api/openapi.yaml | 10 ++- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/pl.json | 1 + src/app/api/illness/insights/route.ts | 23 ++++++- .../illness/illness-insights-card.tsx | 19 ++++-- src/components/illness/types.ts | 1 + .../illness/__tests__/retrospective.test.ts | 68 +++++++++++++++++++ src/lib/illness/retrospective.ts | 32 +++++++++ src/lib/openapi/routes/illness.ts | 3 +- 13 files changed, 152 insertions(+), 10 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index f5cc0e154..14d3a2fe2 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -20434,6 +20434,10 @@ components: type: integer minimum: -9007199254740991 maximum: 9007199254740991 + gapDriverType: + anyOf: + - type: string + - type: "null" required: - windowDays - episodeCount @@ -20442,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 f11ec4bab..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( @@ -130,6 +138,15 @@ export const GET = apiHandler(async (request: NextRequest) => { 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)) + : [], }, ]; }, @@ -148,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, }; }); @@ -160,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 ec3563e1b..6bda26316 100644 --- a/src/components/illness/types.ts +++ b/src/components/illness/types.ts @@ -149,4 +149,5 @@ export interface IllnessInsightsResponse { gapSampleSize: number; byMonth: Record; byType: Record; + gapDriverType: string | null; } 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/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 2c8bc9571..900f49119 100644 --- a/src/lib/openapi/routes/illness.ts +++ b/src/lib/openapi/routes/illness.ts @@ -253,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 = {