mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
commit
b27d8509cd
16 files changed
+363
-101
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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(),
|
||||
});
|
||||
Reference in new issue
Block a user