feat(illness): name the driving vital in the recovery-gap summary

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-21 14:11:20 +02:00
1 parent 36affd5daf
commit 638bc6c835
13 files changed
+152 -10

No files matched your search

+8 -2
View File
@@ -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:
+1
View File
@@ -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"
+1
View File
@@ -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"
+1
View File
@@ -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"
+1
View File
@@ -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"
+1
View File
@@ -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"
+1
View File
@@ -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"
+21 -2
View File
@@ -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,
},
});
@@ -87,13 +87,22 @@ export function IllnessInsightsCard({
<p className="text-muted-foreground">
{t("illness.insights.typicalGapLearning")}
</p>
) : gap.data.typicalRecoveryGapDays === null ? (
<p className="text-muted-foreground">
{t("illness.insights.typicalGapLearning")}
</p>
) : gap.data.gapDriverType ? (
<p className="text-muted-foreground">
{t("illness.insights.typicalGapWithDriver", {
days: gap.data.typicalRecoveryGapDays,
vital: t(`illness.vital.${gap.data.gapDriverType}`),
})}
</p>
) : (
<p className="text-muted-foreground">
{gap.data.typicalRecoveryGapDays !== null
? t("illness.insights.typicalGap", {
days: gap.data.typicalRecoveryGapDays,
})
: t("illness.insights.typicalGapLearning")}
{t("illness.insights.typicalGap", {
days: gap.data.typicalRecoveryGapDays,
})}
</p>
)}
</div>
+1
View File
@@ -149,4 +149,5 @@ export interface IllnessInsightsResponse {
gapSampleSize: number;
byMonth: Record<string, number>;
byType: Record<string, number>;
gapDriverType: string | null;
}
@@ -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 }),
+32
View File
@@ -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<number, number>;
/** Per-type episode counts in the window (retrospective breakdown). */
byType: Record<string, number>;
/**
* 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<string, number> = {};
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<string, number> = {};
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,
};
}
+2 -1
View File
@@ -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 = {