perf(illness): compute the recovery gap lazily and gate it on signal density

The illness list load triggered the cross-episode correlation fan-out on
every mount, so the page waited on up to 24 per-episode reads before it
painted. Move that work behind an explicit opt-in: the insights endpoint
takes an includeRecoveryGap query param (default off), the list reads the
count breakdown on a single fast query, and the recovery gap is computed
only when the viewer opens the Analyse expansion.

Tighten the typical-gap gate against coincidental data. A gap now counts
only from an episode that cleared a per-episode measurement floor
(MIN_GAP_MEASUREMENT_DAYS), at least MIN_EPISODES_FOR_TYPICAL_GAP such
episodes must qualify, and the median magnitude must reach
MIN_TYPICAL_GAP_MAGNITUDE_DAYS — a 0/+-1-day gap is the coincidental
baseline and is withheld as noise.
This commit is contained in:
Marc-André Bombeck committed 2026-06-20 11:58:39 +02:00
1 parent a5b3715a46
commit 1de63f2b5c
15 files changed
+313 -84

No files matched your search

@@ -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>
+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}`,
),
});
}