diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 6ecb970d7..08e540df6 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -6904,6 +6904,13 @@ paths: type: integer minimum: 30 maximum: 1095 + - in: query + name: includeRecoveryGap + schema: + type: string + enum: + - "true" + - "false" responses: "200": description: The cross-episode retrospective summary. diff --git a/messages/de.json b/messages/de.json index 58283042f..1965004ee 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6685,6 +6685,10 @@ "illness": { "title": "Krankheitstagebuch", "subtitle": "Ein rückblickendes Protokoll deiner Krankheits- und Beschwerde-Episoden.", + "settings": { + "viewDescription": "Wähle, ob Episoden als Karten oder als kompakte Liste erscheinen.", + "orderDescription": "Ziehe deine Episoden in die gewünschte Reihenfolge." + }, "customize": "Krankheitstagebuch anpassen", "newEpisode": "Neue Episode", "logDay": "Tag erfassen", @@ -6785,7 +6789,8 @@ "countOther": "Du hast in diesem Zeitraum {count} Episoden erfasst.", "typicalGap": "Typische Erholungslücke: {days} Tage zwischen Besserung und normalisierten Werten.", "typicalGapLearning": "Noch ein paar abgeschlossene Episoden und hier erscheint eine typische Erholungslücke.", - "none": "In diesem Zeitraum keine Episoden erfasst." + "none": "In diesem Zeitraum keine Episoden erfasst.", + "analyze": "Erholungslücke analysieren" }, "section": { "active": "Aktiv", diff --git a/messages/en.json b/messages/en.json index 96c570bb9..f17baab6d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6685,6 +6685,10 @@ "illness": { "title": "Illness journal", "subtitle": "A retrospective log of your illness and condition episodes.", + "settings": { + "viewDescription": "Choose whether episodes show as cards or as a compact list.", + "orderDescription": "Drag your episodes into the order you want to see them in." + }, "customize": "Customize illness journal", "newEpisode": "New episode", "logDay": "Log a day", @@ -6785,7 +6789,8 @@ "countOther": "You logged {count} episodes in this period.", "typicalGap": "Typical recovery gap: {days} days between feeling better and your numbers settling.", "typicalGapLearning": "A few more resolved episodes and a typical recovery gap will appear here.", - "none": "No episodes logged in this period." + "none": "No episodes logged in this period.", + "analyze": "Analyse the recovery gap" }, "section": { "active": "Active", diff --git a/messages/es.json b/messages/es.json index 83b707c6d..98d005164 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6685,6 +6685,10 @@ "illness": { "title": "Diario de enfermedad", "subtitle": "Un registro retrospectivo de tus episodios de enfermedad y dolencias.", + "settings": { + "viewDescription": "Elige si los episodios se muestran como tarjetas o como lista compacta.", + "orderDescription": "Arrastra tus episodios al orden en que quieres verlos." + }, "customize": "Personalizar diario de enfermedad", "newEpisode": "Nuevo episodio", "logDay": "Registrar un día", @@ -6785,7 +6789,8 @@ "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.", "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." + "none": "Sin episodios registrados en este período.", + "analyze": "Analizar la brecha de recuperación" }, "section": { "active": "Activos", diff --git a/messages/fr.json b/messages/fr.json index 1d9e2dede..24eb16649 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6685,6 +6685,10 @@ "illness": { "title": "Journal de maladie", "subtitle": "Un suivi rétrospectif de vos épisodes de maladie et d'affections.", + "settings": { + "viewDescription": "Choisis si les épisodes s'affichent en cartes ou en liste compacte.", + "orderDescription": "Fais glisser tes épisodes dans l'ordre où tu veux les voir." + }, "customize": "Personnaliser le journal de maladie", "newEpisode": "Nouvel épisode", "logDay": "Enregistrer un jour", @@ -6785,7 +6789,8 @@ "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.", "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." + "none": "Aucun épisode enregistré sur cette période.", + "analyze": "Analyser l'écart de récupération" }, "section": { "active": "Actifs", diff --git a/messages/it.json b/messages/it.json index f423e0ed7..d00f64d89 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6685,6 +6685,10 @@ "illness": { "title": "Diario delle malattie", "subtitle": "Un registro retrospettivo dei tuoi episodi di malattia e disturbi.", + "settings": { + "viewDescription": "Scegli se gli episodi appaiono come schede o come elenco compatto.", + "orderDescription": "Trascina i tuoi episodi nell'ordine in cui vuoi vederli." + }, "customize": "Personalizza diario delle malattie", "newEpisode": "Nuovo episodio", "logDay": "Registra un giorno", @@ -6785,7 +6789,8 @@ "countOther": "Hai registrato {count} episodi in questo periodo.", "typicalGap": "Divario di recupero tipico: {days} giorni tra il sentirti meglio e la stabilizzazione dei valori.", "typicalGapLearning": "Ancora qualche episodio risolto e qui apparirà un divario di recupero tipico.", - "none": "Nessun episodio registrato in questo periodo." + "none": "Nessun episodio registrato in questo periodo.", + "analyze": "Analizza il divario di recupero" }, "section": { "active": "Attivi", diff --git a/messages/pl.json b/messages/pl.json index 19d005ecd..a6b1c28c5 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6685,6 +6685,10 @@ "illness": { "title": "Dziennik chorób", "subtitle": "Retrospektywny zapis Twoich epizodów chorób i dolegliwości.", + "settings": { + "viewDescription": "Wybierz, czy epizody mają być wyświetlane jako karty czy zwarta lista.", + "orderDescription": "Przeciągnij epizody w kolejność, w jakiej chcesz je widzieć." + }, "customize": "Dostosuj dziennik chorób", "newEpisode": "Nowy epizod", "logDay": "Zapisz dzień", @@ -6785,7 +6789,8 @@ "countOther": "W tym okresie zapisałeś {count} epizodów.", "typicalGap": "Typowa luka rekonwalescencji: {days} dni między poprawą a ustabilizowaniem wartości.", "typicalGapLearning": "Jeszcze kilka zakończonych epizodów i pojawi się tu typowa luka rekonwalescencji.", - "none": "Brak epizodów zapisanych w tym okresie." + "none": "Brak epizodów zapisanych w tym okresie.", + "analyze": "Przeanalizuj lukę rekonwalescencji" }, "section": { "active": "Aktywne", diff --git a/src/app/api/illness/insights/route.ts b/src/app/api/illness/insights/route.ts index 1723f5f94..1e47c0482 100644 --- a/src/app/api/illness/insights/route.ts +++ b/src/app/api/illness/insights/route.ts @@ -5,10 +5,18 @@ * * RETROSPECTIVE ONLY — it summarises past episodes. It is NEVER a predictor or * diagnoser; the recurrence figure is a count, not a forecast. Server- - * authoritative + gated: the "typical gap" is withheld below the min-sample - * floor (asserts nothing thin). Per-episode gaps come from the same coverage- - * gated correlation engine the per-episode route uses, so a thin episode - * contributes no gap rather than a wrong one. Born-gated + owner-scoped. + * authoritative + gated: the "typical gap" is withheld below the signal- + * density floor (asserts nothing thin). Per-episode gaps come from the same + * coverage-gated correlation engine the per-episode route uses, so a thin + * episode contributes no gap rather than a wrong one. Born-gated + owner- + * scoped. + * + * LAZY BY DEFAULT — the recovery-gap is the only expensive part (a bounded + * per-episode correlation fan-out). The illness LIST loads with + * `includeRecoveryGap` unset, so it pays only for a single count query and + * paints fast; the gap is computed solely when the user opens the explicit + * "Analyse" expansion (`includeRecoveryGap=true`). With it off the summary + * returns the count breakdown and a null typical-gap. */ import { NextRequest } from "next/server"; import pLimit from "p-limit"; @@ -40,9 +48,10 @@ export const GET = apiHandler(async (request: NextRequest) => { const gate = await requireIllnessEnabled(user.id); if (!gate.enabled) return gate.response; + const params = new URL(request.url).searchParams; const parsed = illnessInsightsQuerySchema.safeParse({ - windowDays: - new URL(request.url).searchParams.get("windowDays") ?? undefined, + windowDays: params.get("windowDays") ?? undefined, + includeRecoveryGap: params.get("includeRecoveryGap") ?? undefined, }); if (!parsed.success) { return returnAllZodIssues(parsed.error, 422, { @@ -51,6 +60,7 @@ export const GET = apiHandler(async (request: NextRequest) => { } const windowDays = parsed.data.windowDays ?? 365; + const includeRecoveryGap = parsed.data.includeRecoveryGap ?? false; const now = new Date(); const since = new Date(now.getTime() - windowDays * MS_PER_DAY); const tz = resolveUserTimezone(user.timezone ?? null); @@ -67,45 +77,72 @@ export const GET = apiHandler(async (request: NextRequest) => { }, }); - // Compute the recovery-gap only for resolved, non-chronic episodes (the - // only ones that CAN have a gap), bounded to MAX_GAP_COMPUTATIONS most - // recent. Each gap comes from the coverage-gated engine — a thin episode - // yields null and simply doesn't contribute to the typical-gap median. - const gapCandidates = rows - .filter((r) => r.resolvedAt !== null && r.lifecycle !== "CHRONIC_ONGOING") - .slice(0, MAX_GAP_COMPUTATIONS); - const limit = pLimit(EPISODE_GAP_CONCURRENCY); - const gapEntries = await Promise.all( - gapCandidates.map((r) => - limit(async (): Promise<[string, number | null]> => { - const derived = await computeEpisodeCorrelation( - user.id, - { - id: r.id, - onsetAt: r.onsetAt, - resolvedAt: r.resolvedAt, - lifecycle: r.lifecycle, + // The recovery-gap is the only expensive part. LIST loads skip it + // entirely (`includeRecoveryGap` off) so the page paints on the single + // `findMany` above; the explicit "Analyse" expansion turns it on and pays + // the bounded fan-out. Per-episode gap + coverage come from the coverage- + // gated engine — a thin episode yields null/low coverage and is filtered + // out of the typical-gap median by the aggregate's signal-density gates. + const gapById = new Map< + string, + { recoveryGapDays: number | null; gapMeasurementDays: number } + >(); + if (includeRecoveryGap) { + const gapCandidates = rows + .filter((r) => r.resolvedAt !== null && r.lifecycle !== "CHRONIC_ONGOING") + .slice(0, MAX_GAP_COMPUTATIONS); + const limit = pLimit(EPISODE_GAP_CONCURRENCY); + const gapEntries = await Promise.all( + gapCandidates.map((r) => + limit( + async (): Promise< + [ + string, + { recoveryGapDays: number | null; gapMeasurementDays: number }, + ] + > => { + const derived = await computeEpisodeCorrelation( + user.id, + { + id: r.id, + onsetAt: r.onsetAt, + resolvedAt: r.resolvedAt, + lifecycle: r.lifecycle, + }, + tz, + now, + ); + return [ + r.id, + { + recoveryGapDays: + 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, + }, + ]; }, - tz, - now, - ); - return [ - r.id, - derived.status === "ok" ? derived.value.recoveryGapDays : null, - ]; - }), - ), - ); - const gapById = new Map(gapEntries); + ), + ), + ); + for (const [id, entry] of gapEntries) gapById.set(id, entry); + } - const episodes: RetrospectiveEpisode[] = rows.map((r) => ({ - id: r.id, - type: r.type, - onsetDay: dayKeyForUserTz(r.onsetAt, tz), - resolved: r.resolvedAt !== null, - recoveryGapDays: gapById.get(r.id) ?? null, - lifecycle: r.lifecycle, - })); + const episodes: RetrospectiveEpisode[] = rows.map((r) => { + const gap = gapById.get(r.id); + return { + id: r.id, + type: r.type, + onsetDay: dayKeyForUserTz(r.onsetAt, tz), + resolved: r.resolvedAt !== null, + recoveryGapDays: gap?.recoveryGapDays ?? null, + gapMeasurementDays: gap?.gapMeasurementDays ?? 0, + lifecycle: r.lifecycle, + }; + }); const summary = summarizeIllnessRetrospective(episodes); @@ -115,6 +152,7 @@ export const GET = apiHandler(async (request: NextRequest) => { episode_count: summary.episodeCount, gap_sample_size: summary.gapSampleSize, typical_gap_days: summary.typicalRecoveryGapDays, + include_recovery_gap: includeRecoveryGap, }, }); diff --git a/src/components/illness/illness-insights-card.tsx b/src/components/illness/illness-insights-card.tsx index 28edcea83..15ea9f02f 100644 --- a/src/components/illness/illness-insights-card.tsx +++ b/src/components/illness/illness-insights-card.tsx @@ -2,12 +2,18 @@ /** * v1.18.1 P3 — the cross-episode retrospective summary card ("Your illness - * history"): how many episodes in the window + the typical recovery gap, with - * a calm "still learning" line until enough resolved episodes back the figure. - * Retrospective only — a count of the past, never a forecast. + * history"): how many episodes in the window, and — on demand — the typical + * recovery gap. Retrospective only — a count of the past, never a forecast. + * + * v1.18.9 — the card paints instantly on a single count query + * (`includeRecoveryGap` off). The recovery gap is the only expensive figure + * (a bounded per-episode correlation fan-out), so it is computed LAZILY: it + * runs only when the user opens the "Analyse" expansion, never on list load. */ +import { useState } from "react"; import { History } from "lucide-react"; +import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; import { useTranslations } from "@/lib/i18n/context"; @@ -20,7 +26,18 @@ export function IllnessInsightsCard({ windowDays?: number; }) { const { t } = useTranslations(); - const { data, isLoading, isError } = useIllnessInsights(windowDays); + // Lightweight count read — fires on mount, no recovery-gap fan-out. + const counts = useIllnessInsights(windowDays); + + // The heavy recovery-gap read stays disabled until the user opens it. + const [analyzeOpen, setAnalyzeOpen] = useState(false); + const gap = useIllnessInsights(windowDays, { + includeRecoveryGap: true, + enabled: analyzeOpen, + }); + + const data = counts.data; + const hasEpisodes = !counts.isError && !!data && data.episodeCount > 0; return ( @@ -33,35 +50,52 @@ export function IllnessInsightsCard({ - {isLoading ? ( + {counts.isLoading ? (
- ) : isError || !data ? ( -

- {t("illness.insights.none")} -

- ) : data.episodeCount === 0 ? ( + ) : !hasEpisodes ? (

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

) : ( -
+

- {data.episodeCount === 1 + {data!.episodeCount === 1 ? t("illness.insights.countOne") : t("illness.insights.countOther", { - count: data.episodeCount, + count: data!.episodeCount, })}

-

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

+ + {/* The recovery gap is computed only when the user asks for it. */} + {!analyzeOpen ? ( + + ) : gap.isLoading ? ( +
+ +
+ ) : gap.isError || !gap.data ? ( +

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

+ ) : ( +

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

+ )}
)} diff --git a/src/components/illness/illness-settings.tsx b/src/components/illness/illness-settings.tsx index 7b33496f3..d6952bc5e 100644 --- a/src/components/illness/illness-settings.tsx +++ b/src/components/illness/illness-settings.tsx @@ -6,8 +6,18 @@ * Reached from the wrench beside the page's "hinzufügen" button. Lets the * user reorder conditions/episodes + pick the card-vs-list page view. Order * + view persist client-side via `useModuleListPrefs("illness")`. + * + * v1.18.9 — restructured onto the canonical Settings pattern: each block is a + * `` with a `` (neutral icon + `text-lg` + * title + a short muted description), matching the Stimmung / Medikamente + * sections rather than the bespoke bare-`
` headings it carried + * before. */ +import { Eye, ListOrdered } from "lucide-react"; + import { Skeleton } from "@/components/ui/skeleton"; +import { SettingsCard } from "@/components/settings/settings-card"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { ModuleViewToggle } from "@/components/module-list/module-view-toggle"; import { @@ -37,23 +47,22 @@ export function IllnessSettings() { })); return ( -
-
-
-

- {t("moduleList.viewHeading")} -

- -
-

- {t("moduleList.viewDescription")} -

-
+
+ + } + /> + -
-

- {t("moduleList.reorder.heading")} -

+ + {isLoading ? (
{Array.from({ length: 3 }, (_, i) => ( @@ -63,7 +72,7 @@ export function IllnessSettings() { ) : ( )} -
+
); } diff --git a/src/components/illness/use-illness.ts b/src/components/illness/use-illness.ts index 75ecf6866..70be6ef08 100644 --- a/src/components/illness/use-illness.ts +++ b/src/components/illness/use-illness.ts @@ -61,13 +61,26 @@ export function useIllnessCorrelation(episodeId: string | null) { }); } -/** The cross-episode retrospective summary over a trailing window in days. */ -export function useIllnessInsights(windowDays = 365) { +/** + * The cross-episode retrospective summary over a trailing window in days. + * + * `includeRecoveryGap` is OFF by default: the count breakdown is served by a + * single fast query and the recovery-gap stays null. The illness list mounts + * this lightweight read; only the explicit "Analyse" expansion opts into the + * expensive per-episode correlation (`includeRecoveryGap: true`), which caches + * under its own key. `enabled` lets a caller defer the heavy read until opened. + */ +export function useIllnessInsights( + windowDays = 365, + options: { includeRecoveryGap?: boolean; enabled?: boolean } = {}, +) { + const includeRecoveryGap = options.includeRecoveryGap ?? false; return useQuery({ - queryKey: queryKeys.illnessInsights(windowDays), + queryKey: queryKeys.illnessInsights(windowDays, includeRecoveryGap), + enabled: options.enabled ?? true, queryFn: () => apiGet( - `/api/illness/insights?windowDays=${windowDays}`, + `/api/illness/insights?windowDays=${windowDays}&includeRecoveryGap=${includeRecoveryGap}`, ), }); } diff --git a/src/lib/illness/__tests__/retrospective.test.ts b/src/lib/illness/__tests__/retrospective.test.ts index 195224c59..878072f52 100644 --- a/src/lib/illness/__tests__/retrospective.test.ts +++ b/src/lib/illness/__tests__/retrospective.test.ts @@ -2,13 +2,17 @@ * v1.18.1 P3 — illness retrospective summary aggregation. * * Asserts the count/byMonth/byType tallies, the CHRONIC_ONGOING gap exclusion, - * and the thin-data gate: the "typical recovery gap" is WITHHELD below the - * min-sample floor (never a "typical" claim from one episode). + * and the v1.18.9 signal-density gates: the "typical recovery gap" is WITHHELD + * unless enough episodes EACH clear the per-episode measurement floor AND the + * resulting median magnitude is non-trivial (a 0/±1-day gap is coincidental + * noise, not a finding). */ import { describe, expect, it } from "vitest"; import { summarizeIllnessRetrospective, MIN_EPISODES_FOR_TYPICAL_GAP, + MIN_GAP_MEASUREMENT_DAYS, + MIN_TYPICAL_GAP_MAGNITUDE_DAYS, type RetrospectiveEpisode, } from "../retrospective"; @@ -19,6 +23,9 @@ function ep(over: Partial): RetrospectiveEpisode { onsetDay: "2026-01-10", resolved: true, recoveryGapDays: 2, + // Default to a well-covered episode so the gap-magnitude / sample-count + // tests aren't masked by the per-episode measurement floor. + gapMeasurementDays: MIN_GAP_MEASUREMENT_DAYS + 2, lifecycle: "ACUTE", ...over, }; @@ -76,4 +83,60 @@ describe("summarizeIllnessRetrospective", () => { expect(out.gapSampleSize).toBe(3); expect(out.typicalRecoveryGapDays).toBe(4); }); + + it("drops episodes below the per-episode measurement floor", () => { + // Three computed gaps, but only one episode is well-covered — the other + // two ride on a couple of coincidental readings and must not count. + const out = summarizeIllnessRetrospective([ + ep({ id: "a", recoveryGapDays: 4, gapMeasurementDays: 8 }), + ep({ + id: "b", + recoveryGapDays: 5, + gapMeasurementDays: MIN_GAP_MEASUREMENT_DAYS - 1, + }), + ep({ id: "c", recoveryGapDays: 6, gapMeasurementDays: 0 }), + ]); + expect(out.gapSampleSize).toBe(1); + expect(out.typicalRecoveryGapDays).toBeNull(); + }); + + it("treats a missing gapMeasurementDays as non-qualifying", () => { + const bare = (id: string): RetrospectiveEpisode => ({ + id, + type: "INFECTION", + onsetDay: "2026-01-10", + resolved: true, + recoveryGapDays: 4, + lifecycle: "ACUTE", + }); + const out = summarizeIllnessRetrospective([ + bare("a"), + bare("b"), + bare("c"), + ]); + expect(out.gapSampleSize).toBe(0); + expect(out.typicalRecoveryGapDays).toBeNull(); + }); + + it("withholds a trivial (sub-threshold) median gap as noise", () => { + // Enough well-covered episodes, but the gaps cluster at 0/±1 — the + // coincidental-data baseline. No speculative gap is surfaced. + const out = summarizeIllnessRetrospective([ + ep({ id: "a", recoveryGapDays: 0 }), + ep({ id: "b", recoveryGapDays: 1 }), + ep({ id: "c", recoveryGapDays: -1 }), + ]); + expect(MIN_TYPICAL_GAP_MAGNITUDE_DAYS).toBe(2); + expect(out.gapSampleSize).toBe(3); // they qualified on coverage… + expect(out.typicalRecoveryGapDays).toBeNull(); // …but the magnitude is noise + }); + + it("surfaces a negative gap once its magnitude clears the floor", () => { + const out = summarizeIllnessRetrospective([ + ep({ id: "a", recoveryGapDays: -2 }), + ep({ id: "b", recoveryGapDays: -3 }), + ep({ id: "c", recoveryGapDays: -4 }), + ]); + expect(out.typicalRecoveryGapDays).toBe(-3); + }); }); diff --git a/src/lib/illness/retrospective.ts b/src/lib/illness/retrospective.ts index 08ab725f3..ff93e92b0 100644 --- a/src/lib/illness/retrospective.ts +++ b/src/lib/illness/retrospective.ts @@ -15,9 +15,36 @@ */ import { median } from "@/lib/insights/derived/baseline"; -/** Minimum resolved-with-gap episodes before a "typical gap" is asserted. */ +/** + * Minimum episodes that each clear the per-episode signal floor before a + * "typical recovery gap" is asserted. A gap is only honest once several + * episodes independently back it, so one or two coincidental gaps never + * become a "typical" claim. + */ export const MIN_EPISODES_FOR_TYPICAL_GAP = 3; +/** + * Minimum distinct episode days carrying a real measurement before an + * episode's recovery-gap may feed the typical-gap median. Below this an + * episode's window holds too few genuine vitals for its computed gap to mean + * anything — a couple of readings happening to surround the onset/recovery + * markers produce a spurious gap. The correlation engine's own + * `MIN_EPISODE_COVERAGE_DAYS` floor (4) is the engine-side gate; this is the + * aggregate-side reinforcement so a barely-covered episode does not tip the + * median. Sourced from `Derived.coverage.historyDays`. + */ +export const MIN_GAP_MEASUREMENT_DAYS = 4; + +/** + * Minimum absolute magnitude (days) the typical gap must reach before it is + * surfaced. A median gap of 0 — and the ±1-day jitter around it — is the + * dominant outcome of coincidental data (numbers and feeling settle on the + * same day), so it carries no signal worth a headline. Only a gap that + * genuinely separates the felt-better marker from the physiological return + * is shown. + */ +export const MIN_TYPICAL_GAP_MAGNITUDE_DAYS = 2; + /** One episode's contribution to the summary. */ export interface RetrospectiveEpisode { id: string; @@ -28,6 +55,13 @@ export interface RetrospectiveEpisode { resolved: boolean; /** The computed recovery-gap in days, or null when ungated/unavailable. */ recoveryGapDays: number | null; + /** + * Distinct episode days that carried a real measurement (the correlation + * engine's coverage `historyDays`). Drives the per-episode signal floor: + * an episode below `MIN_GAP_MEASUREMENT_DAYS` contributes no gap even if + * the engine computed one. Absent/0 → does not qualify. + */ + gapMeasurementDays?: number; /** Lifecycle — CHRONIC_ONGOING never contributes a gap. */ lifecycle: string; } @@ -56,9 +90,17 @@ export interface IllnessRetrospectiveSummary { } /** - * Aggregate the per-episode figures into the retrospective summary. Pure. The - * typical-gap is withheld (null) below the min-sample floor so a single - * episode never becomes a "typical" claim. + * Aggregate the per-episode figures into the retrospective summary. Pure. + * + * The typical-gap is withheld (null) unless ALL THREE signal-density gates + * hold, so coincidental data never surfaces a speculative "Erholungslücke": + * 1. each contributing episode cleared the per-episode measurement floor + * (`gapMeasurementDays >= MIN_GAP_MEASUREMENT_DAYS`), + * 2. at least `MIN_EPISODES_FOR_TYPICAL_GAP` such episodes qualify, and + * 3. the resulting median magnitude reaches + * `MIN_TYPICAL_GAP_MAGNITUDE_DAYS` (a 0/±1-day gap is noise). + * `gapSampleSize` reports the count of episodes that cleared gates 1–2 (the + * transparency figure), independent of the magnitude gate. */ export function summarizeIllnessRetrospective( episodes: RetrospectiveEpisode[], @@ -76,17 +118,27 @@ export function summarizeIllnessRetrospective( if ( e.lifecycle !== "CHRONIC_ONGOING" && e.recoveryGapDays !== null && - Number.isFinite(e.recoveryGapDays) + Number.isFinite(e.recoveryGapDays) && + // Per-episode signal floor: a barely-covered episode contributes no + // gap even if the engine computed one. + (e.gapMeasurementDays ?? 0) >= MIN_GAP_MEASUREMENT_DAYS ) { gaps.push(e.recoveryGapDays); } } - const typicalRecoveryGapDays = + const medianGap = gaps.length >= MIN_EPISODES_FOR_TYPICAL_GAP ? Math.round(median(gaps)) : null; + // Magnitude gate: a 0/±1-day gap is the coincidental-data baseline and + // carries no signal worth a headline. + const typicalRecoveryGapDays = + medianGap !== null && Math.abs(medianGap) >= MIN_TYPICAL_GAP_MAGNITUDE_DAYS + ? medianGap + : null; + return { episodeCount: episodes.length, resolvedCount, diff --git a/src/lib/openapi/routes/illness.ts b/src/lib/openapi/routes/illness.ts index 14b34e3b1..8759a005b 100644 --- a/src/lib/openapi/routes/illness.ts +++ b/src/lib/openapi/routes/illness.ts @@ -93,7 +93,7 @@ const illnessDayLogGetQuery = z illnessInsightsQuerySchema.meta({ id: "IllnessInsightsQuery", description: - "Cross-episode retrospective window query: optional `windowDays` (30–1095, default 365). Retrospective only — the engine summarises past episodes, never forecasts.", + "Cross-episode retrospective window query: optional `windowDays` (30–1095, default 365) and optional `includeRecoveryGap` (default false). With `includeRecoveryGap` off the response carries the count breakdown and a null typical recovery gap on a single fast query; set it true to pay for the per-episode correlation that computes the recovery gap. Retrospective only — the engine summarises past episodes, never forecasts.", }); const illnessSymptom = z diff --git a/src/lib/query-keys/illness.ts b/src/lib/query-keys/illness.ts index 79e4f1967..751180365 100644 --- a/src/lib/query-keys/illness.ts +++ b/src/lib/query-keys/illness.ts @@ -35,7 +35,13 @@ export const illnessKeys = { /** * v1.18.1 P3 — the cross-episode retrospective summary ("sick N times · * typical recovery gap X days") over a trailing window in days. + * + * v1.18.9 — `includeRecoveryGap` is part of the key: the lightweight + * count-only read (list load) and the heavy recovery-gap read (the + * "Analyse" expansion) hit the same endpoint with a different response + * shape, so they MUST cache under distinct keys (same-key + different + * payload silently poisons the cache). */ - illnessInsights: (windowDays: number) => - ["illness", "insights", windowDays] as const, + illnessInsights: (windowDays: number, includeRecoveryGap: boolean) => + ["illness", "insights", windowDays, includeRecoveryGap] as const, }; diff --git a/src/lib/validations/illness.ts b/src/lib/validations/illness.ts index 7578df218..97b0a29d6 100644 --- a/src/lib/validations/illness.ts +++ b/src/lib/validations/illness.ts @@ -182,7 +182,17 @@ export type IllnessDayLogListQuery = z.infer< * The retrospective-insight window query: `GET /api/illness/insights? * windowDays=365`. Bounded 30..1095 days; defaults to a trailing year. * Retrospective only — the engine summarises past episodes, never forecasts. + * + * `includeRecoveryGap` (default false) gates the EXPENSIVE per-episode + * correlation fan-out. The illness LIST loads with it off so the page paints + * on a single fast count query; only the explicit "Analyse" expansion sets it + * true and pays for the recovery-gap computation. Withholding it leaves the + * typical-gap null (the surface shows the calm "still learning" line). */ export const illnessInsightsQuerySchema = z.object({ windowDays: z.coerce.number().int().min(30).max(1095).optional(), + includeRecoveryGap: z + .enum(["true", "false"]) + .transform((v) => v === "true") + .optional(), });