Merge v189/ill into release/v1.18.9

Defer illness recovery-gap correlation to an opt-in detail expansion; gate the
typical-gap insight behind signal-density thresholds; align the illness settings
page to the canonical SettingsCard pattern.
This commit is contained in:
Marc-André Bombeck committed 2026-06-20 12:00:00 +02:00
commit b27d8509cd
16 files changed
+363 -101

No files matched your search

+7
View File
@@ -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.
+6 -1
View File
@@ -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",
+6 -1
View File
@@ -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",
+6 -1
View File
@@ -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",
+6 -1
View File
@@ -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",
+6 -1
View File
@@ -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",
+6 -1
View File
@@ -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",
+81 -43
View File
@@ -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<string, number | null>(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,
},
});
@@ -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 (
<Card>
@@ -33,35 +50,52 @@ export function IllnessInsightsCard({
</div>
</CardHeader>
<CardContent>
{isLoading ? (
{counts.isLoading ? (
<div className="space-y-3">
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-4 w-1/2" />
</div>
) : isError || !data ? (
<p className="text-muted-foreground text-sm">
{t("illness.insights.none")}
</p>
) : data.episodeCount === 0 ? (
) : !hasEpisodes ? (
<p className="text-muted-foreground text-sm">
{t("illness.insights.none")}
</p>
) : (
<div className="space-y-2 text-sm">
<div className="space-y-4 text-sm">
<p className="text-foreground">
{data.episodeCount === 1
{data!.episodeCount === 1
? t("illness.insights.countOne")
: t("illness.insights.countOther", {
count: data.episodeCount,
count: data!.episodeCount,
})}
</p>
<p className="text-muted-foreground">
{data.typicalRecoveryGapDays !== null
? t("illness.insights.typicalGap", {
days: data.typicalRecoveryGapDays,
})
: t("illness.insights.typicalGapLearning")}
</p>
{/* The recovery gap is computed only when the user asks for it. */}
{!analyzeOpen ? (
<Button
variant="outline"
size="sm"
className="min-h-9"
onClick={() => setAnalyzeOpen(true)}
>
{t("illness.insights.analyze")}
</Button>
) : gap.isLoading ? (
<div className="space-y-2">
<Skeleton className="h-4 w-1/2" />
</div>
) : gap.isError || !gap.data ? (
<p className="text-muted-foreground">
{t("illness.insights.typicalGapLearning")}
</p>
) : (
<p className="text-muted-foreground">
{gap.data.typicalRecoveryGapDays !== null
? t("illness.insights.typicalGap", {
days: gap.data.typicalRecoveryGapDays,
})
: t("illness.insights.typicalGapLearning")}
</p>
)}
</div>
)}
</CardContent>
+26 -17
View File
@@ -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
* `<SettingsCard>` with a `<SettingsCardHeader>` (neutral icon + `text-lg`
* title + a short muted description), matching the Stimmung / Medikamente
* sections rather than the bespoke bare-`<section>` 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 (
<div className="space-y-8">
<section className="space-y-3">
<div className="flex items-center justify-between gap-3">
<h2 className="text-sm font-semibold">
{t("moduleList.viewHeading")}
</h2>
<ModuleViewToggle view={prefs.view} onChange={setView} />
</div>
<p className="text-muted-foreground text-sm">
{t("moduleList.viewDescription")}
</p>
</section>
<div className="space-y-6">
<SettingsCard id="illness-view" className="scroll-mt-28 space-y-4">
<SettingsCardHeader
icon={Eye}
title={t("moduleList.viewHeading")}
description={t("illness.settings.viewDescription")}
status={<ModuleViewToggle view={prefs.view} onChange={setView} />}
/>
</SettingsCard>
<section className="space-y-3">
<h2 className="text-sm font-semibold">
{t("moduleList.reorder.heading")}
</h2>
<SettingsCard id="illness-order" className="scroll-mt-28 space-y-4">
<SettingsCardHeader
icon={ListOrdered}
title={t("moduleList.reorder.heading")}
description={t("illness.settings.orderDescription")}
/>
{isLoading ? (
<div className="space-y-2">
{Array.from({ length: 3 }, (_, i) => (
@@ -63,7 +72,7 @@ export function IllnessSettings() {
) : (
<ModuleOrderEditor items={reorderItems} onChange={setOrder} />
)}
</section>
</SettingsCard>
</div>
);
}
+17 -4
View File
@@ -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<IllnessInsightsResponse>(
`/api/illness/insights?windowDays=${windowDays}`,
`/api/illness/insights?windowDays=${windowDays}&includeRecoveryGap=${includeRecoveryGap}`,
),
});
}
@@ -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>): 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);
});
});
+58 -6
View File
@@ -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,
+1 -1
View File
@@ -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
+8 -2
View File
@@ -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,
};
+10
View File
@@ -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(),
});