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 = {