diff --git a/messages/de.json b/messages/de.json
index d5421a396..7d29ecef3 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -6827,7 +6827,9 @@
"onset": "Begonnen am",
"note": "Notiz",
"notePlaceholder": "Was erwähnenswert ist (verschlüsselt).",
- "saveError": "Die Episode konnte nicht gespeichert werden. Bitte erneut versuchen."
+ "saveError": "Die Episode konnte nicht gespeichert werden. Bitte erneut versuchen.",
+ "parent": "Part of",
+ "parentNone": "Not linked"
},
"sheet": {
"title": "{date} erfassen",
@@ -6839,7 +6841,8 @@
"noteSet": "Gesetzt",
"notePlaceholder": "Wie sich der Tag anfühlte (verschlüsselt).",
"severityFor": "Stärke für {symptom}",
- "saveError": "Der Tag konnte nicht gespeichert werden. Bitte erneut versuchen."
+ "saveError": "Der Tag konnte nicht gespeichert werden. Bitte erneut versuchen.",
+ "day": "Day"
},
"impact": {
"0": "Voll belastbar",
@@ -6884,6 +6887,35 @@
"typicalGapLearning": "Noch ein paar abgeschlossene Episoden und hier erscheint eine typische Erholungslücke.",
"byMonth": "Wann du Episoden erfasst",
"none": "In diesem Zeitraum keine Episoden erfasst."
+ },
+ "loadError": "Could not load this episode.",
+ "backToList": "Back to journal",
+ "detail": {
+ "note": "Note"
+ },
+ "edit": {
+ "title": "Edit episode",
+ "description": "Update this episode's details."
+ },
+ "menu": {
+ "label": "Episode actions"
+ },
+ "deleteConfirm": {
+ "title": "Delete this episode?",
+ "body": "“{label}” and all its day-logs will be removed. This cannot be undone."
+ },
+ "vital": {
+ "RESTING_HEART_RATE": "Resting heart rate",
+ "HEART_RATE_VARIABILITY": "Heart-rate variability",
+ "RECOVERY_SCORE": "Recovery",
+ "OXYGEN_SATURATION": "Oxygen saturation",
+ "BODY_TEMPERATURE": "Body temperature",
+ "SKIN_TEMPERATURE": "Skin temperature",
+ "RESPIRATORY_RATE": "Respiratory rate",
+ "BLOOD_PRESSURE_SYS": "Systolic blood pressure",
+ "BLOOD_PRESSURE_DIA": "Diastolic blood pressure",
+ "PULSE": "Pulse",
+ "WEIGHT": "Weight"
}
},
"coach": {
diff --git a/messages/en.json b/messages/en.json
index a1495e837..392904f90 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -6827,7 +6827,9 @@
"onset": "Started on",
"note": "Note",
"notePlaceholder": "Anything worth remembering (encrypted).",
- "saveError": "Could not save the episode. Please try again."
+ "saveError": "Could not save the episode. Please try again.",
+ "parent": "Part of",
+ "parentNone": "Not linked"
},
"sheet": {
"title": "Log {date}",
@@ -6839,7 +6841,8 @@
"noteSet": "Set",
"notePlaceholder": "How the day felt (encrypted).",
"severityFor": "Severity for {symptom}",
- "saveError": "Could not save the day. Please try again."
+ "saveError": "Could not save the day. Please try again.",
+ "day": "Day"
},
"impact": {
"0": "Fully functional",
@@ -6884,6 +6887,35 @@
"typicalGapLearning": "A few more resolved episodes and a typical recovery gap will appear here.",
"byMonth": "When you tend to log episodes",
"none": "No episodes logged in this period."
+ },
+ "loadError": "Could not load this episode.",
+ "backToList": "Back to journal",
+ "detail": {
+ "note": "Note"
+ },
+ "edit": {
+ "title": "Edit episode",
+ "description": "Update this episode's details."
+ },
+ "menu": {
+ "label": "Episode actions"
+ },
+ "deleteConfirm": {
+ "title": "Delete this episode?",
+ "body": "“{label}” and all its day-logs will be removed. This cannot be undone."
+ },
+ "vital": {
+ "RESTING_HEART_RATE": "Resting heart rate",
+ "HEART_RATE_VARIABILITY": "Heart-rate variability",
+ "RECOVERY_SCORE": "Recovery",
+ "OXYGEN_SATURATION": "Oxygen saturation",
+ "BODY_TEMPERATURE": "Body temperature",
+ "SKIN_TEMPERATURE": "Skin temperature",
+ "RESPIRATORY_RATE": "Respiratory rate",
+ "BLOOD_PRESSURE_SYS": "Systolic blood pressure",
+ "BLOOD_PRESSURE_DIA": "Diastolic blood pressure",
+ "PULSE": "Pulse",
+ "WEIGHT": "Weight"
}
},
"coach": {
diff --git a/messages/es.json b/messages/es.json
index 6c88b63ae..0adc83cdd 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -6827,7 +6827,9 @@
"onset": "Comenzó el",
"note": "Nota",
"notePlaceholder": "Algo que valga la pena recordar (cifrado).",
- "saveError": "No se pudo guardar el episodio. Inténtalo de nuevo."
+ "saveError": "No se pudo guardar el episodio. Inténtalo de nuevo.",
+ "parent": "Part of",
+ "parentNone": "Not linked"
},
"sheet": {
"title": "Registrar {date}",
@@ -6839,7 +6841,8 @@
"noteSet": "Definida",
"notePlaceholder": "Cómo te sentiste hoy (cifrado).",
"severityFor": "Gravedad de {symptom}",
- "saveError": "No se pudo guardar el día. Inténtalo de nuevo."
+ "saveError": "No se pudo guardar el día. Inténtalo de nuevo.",
+ "day": "Day"
},
"impact": {
"0": "Plenamente funcional",
@@ -6884,6 +6887,35 @@
"typicalGapLearning": "Con unos pocos episodios resueltos más aparecerá aquí una brecha de recuperación típica.",
"byMonth": "Cuándo sueles registrar episodios",
"none": "Sin episodios registrados en este período."
+ },
+ "loadError": "Could not load this episode.",
+ "backToList": "Back to journal",
+ "detail": {
+ "note": "Note"
+ },
+ "edit": {
+ "title": "Edit episode",
+ "description": "Update this episode's details."
+ },
+ "menu": {
+ "label": "Episode actions"
+ },
+ "deleteConfirm": {
+ "title": "Delete this episode?",
+ "body": "“{label}” and all its day-logs will be removed. This cannot be undone."
+ },
+ "vital": {
+ "RESTING_HEART_RATE": "Resting heart rate",
+ "HEART_RATE_VARIABILITY": "Heart-rate variability",
+ "RECOVERY_SCORE": "Recovery",
+ "OXYGEN_SATURATION": "Oxygen saturation",
+ "BODY_TEMPERATURE": "Body temperature",
+ "SKIN_TEMPERATURE": "Skin temperature",
+ "RESPIRATORY_RATE": "Respiratory rate",
+ "BLOOD_PRESSURE_SYS": "Systolic blood pressure",
+ "BLOOD_PRESSURE_DIA": "Diastolic blood pressure",
+ "PULSE": "Pulse",
+ "WEIGHT": "Weight"
}
},
"coach": {
diff --git a/messages/fr.json b/messages/fr.json
index 079a2b06f..bbc1fc48f 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -6827,7 +6827,9 @@
"onset": "Débuté le",
"note": "Note",
"notePlaceholder": "Ce qui mérite d'être noté (chiffré).",
- "saveError": "Impossible d'enregistrer l'épisode. Veuillez réessayer."
+ "saveError": "Impossible d'enregistrer l'épisode. Veuillez réessayer.",
+ "parent": "Part of",
+ "parentNone": "Not linked"
},
"sheet": {
"title": "Enregistrer {date}",
@@ -6839,7 +6841,8 @@
"noteSet": "Définie",
"notePlaceholder": "Comment s'est passée la journée (chiffré).",
"severityFor": "Intensité de {symptom}",
- "saveError": "Impossible d'enregistrer la journée. Veuillez réessayer."
+ "saveError": "Impossible d'enregistrer la journée. Veuillez réessayer.",
+ "day": "Day"
},
"impact": {
"0": "Pleinement fonctionnel",
@@ -6884,6 +6887,35 @@
"typicalGapLearning": "Encore quelques épisodes résolus et un écart de récupération typique apparaîtra ici.",
"byMonth": "Quand tu enregistres des épisodes",
"none": "Aucun épisode enregistré sur cette période."
+ },
+ "loadError": "Could not load this episode.",
+ "backToList": "Back to journal",
+ "detail": {
+ "note": "Note"
+ },
+ "edit": {
+ "title": "Edit episode",
+ "description": "Update this episode's details."
+ },
+ "menu": {
+ "label": "Episode actions"
+ },
+ "deleteConfirm": {
+ "title": "Delete this episode?",
+ "body": "“{label}” and all its day-logs will be removed. This cannot be undone."
+ },
+ "vital": {
+ "RESTING_HEART_RATE": "Resting heart rate",
+ "HEART_RATE_VARIABILITY": "Heart-rate variability",
+ "RECOVERY_SCORE": "Recovery",
+ "OXYGEN_SATURATION": "Oxygen saturation",
+ "BODY_TEMPERATURE": "Body temperature",
+ "SKIN_TEMPERATURE": "Skin temperature",
+ "RESPIRATORY_RATE": "Respiratory rate",
+ "BLOOD_PRESSURE_SYS": "Systolic blood pressure",
+ "BLOOD_PRESSURE_DIA": "Diastolic blood pressure",
+ "PULSE": "Pulse",
+ "WEIGHT": "Weight"
}
},
"coach": {
diff --git a/messages/it.json b/messages/it.json
index fcea894ef..94a3d7fb5 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -6827,7 +6827,9 @@
"onset": "Iniziato il",
"note": "Nota",
"notePlaceholder": "Qualcosa da ricordare (cifrato).",
- "saveError": "Impossibile salvare l'episodio. Riprova."
+ "saveError": "Impossibile salvare l'episodio. Riprova.",
+ "parent": "Part of",
+ "parentNone": "Not linked"
},
"sheet": {
"title": "Registra {date}",
@@ -6839,7 +6841,8 @@
"noteSet": "Impostata",
"notePlaceholder": "Com'è andata la giornata (cifrato).",
"severityFor": "Intensità di {symptom}",
- "saveError": "Impossibile salvare la giornata. Riprova."
+ "saveError": "Impossibile salvare la giornata. Riprova.",
+ "day": "Day"
},
"impact": {
"0": "Pienamente funzionale",
@@ -6884,6 +6887,35 @@
"typicalGapLearning": "Ancora qualche episodio risolto e qui apparirà un divario di recupero tipico.",
"byMonth": "Quando tendi a registrare episodi",
"none": "Nessun episodio registrato in questo periodo."
+ },
+ "loadError": "Could not load this episode.",
+ "backToList": "Back to journal",
+ "detail": {
+ "note": "Note"
+ },
+ "edit": {
+ "title": "Edit episode",
+ "description": "Update this episode's details."
+ },
+ "menu": {
+ "label": "Episode actions"
+ },
+ "deleteConfirm": {
+ "title": "Delete this episode?",
+ "body": "“{label}” and all its day-logs will be removed. This cannot be undone."
+ },
+ "vital": {
+ "RESTING_HEART_RATE": "Resting heart rate",
+ "HEART_RATE_VARIABILITY": "Heart-rate variability",
+ "RECOVERY_SCORE": "Recovery",
+ "OXYGEN_SATURATION": "Oxygen saturation",
+ "BODY_TEMPERATURE": "Body temperature",
+ "SKIN_TEMPERATURE": "Skin temperature",
+ "RESPIRATORY_RATE": "Respiratory rate",
+ "BLOOD_PRESSURE_SYS": "Systolic blood pressure",
+ "BLOOD_PRESSURE_DIA": "Diastolic blood pressure",
+ "PULSE": "Pulse",
+ "WEIGHT": "Weight"
}
},
"coach": {
diff --git a/messages/pl.json b/messages/pl.json
index 1c7b3ed76..99aa20b2c 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -6827,7 +6827,9 @@
"onset": "Początek",
"note": "Notatka",
"notePlaceholder": "Cokolwiek wartego zapamiętania (zaszyfrowane).",
- "saveError": "Nie udało się zapisać epizodu. Spróbuj ponownie."
+ "saveError": "Nie udało się zapisać epizodu. Spróbuj ponownie.",
+ "parent": "Part of",
+ "parentNone": "Not linked"
},
"sheet": {
"title": "Zapisz {date}",
@@ -6839,7 +6841,8 @@
"noteSet": "Ustawiona",
"notePlaceholder": "Jak minął dzień (zaszyfrowane).",
"severityFor": "Nasilenie: {symptom}",
- "saveError": "Nie udało się zapisać dnia. Spróbuj ponownie."
+ "saveError": "Nie udało się zapisać dnia. Spróbuj ponownie.",
+ "day": "Day"
},
"impact": {
"0": "Pełna sprawność",
@@ -6884,6 +6887,35 @@
"typicalGapLearning": "Jeszcze kilka zakończonych epizodów i pojawi się tu typowa luka rekonwalescencji.",
"byMonth": "Kiedy zwykle zapisujesz epizody",
"none": "Brak epizodów zapisanych w tym okresie."
+ },
+ "loadError": "Could not load this episode.",
+ "backToList": "Back to journal",
+ "detail": {
+ "note": "Note"
+ },
+ "edit": {
+ "title": "Edit episode",
+ "description": "Update this episode's details."
+ },
+ "menu": {
+ "label": "Episode actions"
+ },
+ "deleteConfirm": {
+ "title": "Delete this episode?",
+ "body": "“{label}” and all its day-logs will be removed. This cannot be undone."
+ },
+ "vital": {
+ "RESTING_HEART_RATE": "Resting heart rate",
+ "HEART_RATE_VARIABILITY": "Heart-rate variability",
+ "RECOVERY_SCORE": "Recovery",
+ "OXYGEN_SATURATION": "Oxygen saturation",
+ "BODY_TEMPERATURE": "Body temperature",
+ "SKIN_TEMPERATURE": "Skin temperature",
+ "RESPIRATORY_RATE": "Respiratory rate",
+ "BLOOD_PRESSURE_SYS": "Systolic blood pressure",
+ "BLOOD_PRESSURE_DIA": "Diastolic blood pressure",
+ "PULSE": "Pulse",
+ "WEIGHT": "Weight"
}
},
"coach": {
diff --git a/src/app/illness/[id]/page.tsx b/src/app/illness/[id]/page.tsx
new file mode 100644
index 000000000..8186871ab
--- /dev/null
+++ b/src/app/illness/[id]/page.tsx
@@ -0,0 +1,56 @@
+"use client";
+
+/**
+ * v1.18.1 — per-episode detail page (`/illness/[id]`), mirroring
+ * `/labs/[biomarkerId]`. Born-gated on the resolved `modules.illness` flag;
+ * an unauthenticated visitor is bounced to login, an account without the
+ * module opted in is bounced home (every `/api/illness/*` route also enforces
+ * the gate server-side, so this is a UX redirect, not the security boundary).
+ */
+import { use, useEffect } from "react";
+import { useRouter } from "next/navigation";
+import { Loader2 } from "lucide-react";
+
+import { IllnessEpisodeDetail } from "@/components/illness/illness-episode-detail";
+import { BackLink } from "@/components/ui/back-link";
+import { useAuth } from "@/hooks/use-auth";
+import { useMounted } from "@/hooks/use-mounted";
+import { useTranslations } from "@/lib/i18n/context";
+
+export default function IllnessEpisodePage({
+ params,
+}: {
+ params: Promise<{ id: string }>;
+}) {
+ const { id } = use(params);
+ const { user, isAuthenticated, isLoading } = useAuth();
+ const mounted = useMounted();
+ const router = useRouter();
+ const { t } = useTranslations();
+
+ const enabled = user?.modules?.illness === true;
+
+ useEffect(() => {
+ if (isLoading) return;
+ if (!isAuthenticated) {
+ router.push("/auth/login");
+ } else if (!enabled) {
+ router.push("/");
+ }
+ }, [isLoading, isAuthenticated, enabled, router]);
+
+ if (!mounted || isLoading || !isAuthenticated || !enabled) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ );
+}
diff --git a/src/components/illness/episode-menu.tsx b/src/components/illness/episode-menu.tsx
new file mode 100644
index 000000000..6fe24c37e
--- /dev/null
+++ b/src/components/illness/episode-menu.tsx
@@ -0,0 +1,129 @@
+"use client";
+
+/**
+ * v1.18.1 — shared illness-episode overflow menu (mirrors the medication
+ * card menu): one kebab folding Edit / Mark recovered / Delete. Delete is
+ * gated behind an AlertDialog (every destructive peer guards this; an episode
+ * delete cascades its day-logs + encrypted notes, so a single unguarded tap
+ * was the Medium finding). Neutral throughout.
+ */
+import { useState } from "react";
+import { MoreVertical, Pencil, CheckCircle2, Trash2 } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { useTranslations } from "@/lib/i18n/context";
+
+import { useDeleteEpisode } from "./use-illness";
+import type { IllnessEpisodeDTO } from "./types";
+
+export interface EpisodeMenuProps {
+ episode: IllnessEpisodeDTO;
+ onEdit: () => void;
+ /** Provided only when the episode is active + non-chronic. */
+ onResolve?: () => void;
+ resolving?: boolean;
+ /** Called after a confirmed delete (e.g. navigate back from a detail page). */
+ onDeleted?: () => void;
+}
+
+export function EpisodeMenu({
+ episode,
+ onEdit,
+ onResolve,
+ resolving,
+ onDeleted,
+}: EpisodeMenuProps) {
+ const { t } = useTranslations();
+ const del = useDeleteEpisode();
+ const [confirmOpen, setConfirmOpen] = useState(false);
+
+ async function handleConfirmDelete() {
+ try {
+ await del.mutateAsync(episode.id);
+ setConfirmOpen(false);
+ onDeleted?.();
+ } catch {
+ // Keep the dialog open; the list will surface any error on next read.
+ }
+ }
+
+ return (
+ <>
+
+
+ e.stopPropagation()}
+ >
+
+
+
+
+
+
+ {t("common.edit")}
+
+ {onResolve ? (
+
+
+ {t("illness.markRecovered")}
+
+ ) : null}
+
+ {
+ e.preventDefault();
+ setConfirmOpen(true);
+ }}
+ >
+
+ {t("common.delete")}
+
+
+
+
+
+
+
+
+ {t("illness.deleteConfirm.title")}
+
+
+ {t("illness.deleteConfirm.body", { label: episode.label })}
+
+
+
+ {t("common.cancel")}
+
+ {t("common.delete")}
+
+
+
+
+ >
+ );
+}
diff --git a/src/components/illness/illness-correlation-card.tsx b/src/components/illness/illness-correlation-card.tsx
new file mode 100644
index 000000000..389530700
--- /dev/null
+++ b/src/components/illness/illness-correlation-card.tsx
@@ -0,0 +1,151 @@
+"use client";
+
+/**
+ * v1.18.1 P3 — the per-episode retrospective correlation card ("How it
+ * unfolded"). Server-authoritative + coverage-gated: it pattern-matches the
+ * `Derived` `status` and renders "still learning" when the signal is thin,
+ * NEVER a fabricated number. Retrospective only — never a prediction or a
+ * diagnosis. Neutral palette throughout (the med-card no-colour rule
+ * generalised); the red-flag is a calm "worth a closer look" note, framed to
+ * escalate without alarming colour.
+ */
+import { ArrowDown, ArrowUp, Info } from "lucide-react";
+
+import { Card, CardContent, CardHeader } from "@/components/ui/card";
+import { Skeleton } from "@/components/ui/skeleton";
+import { useTranslations } from "@/lib/i18n/context";
+
+import { useIllnessCorrelation } from "./use-illness";
+import type {
+ IllnessCorrelationValue,
+ IllnessVitalDeviation,
+} from "./types";
+
+/** Translate a MeasurementType to its illness-surface label. */
+function useVitalLabel() {
+ const { t } = useTranslations();
+ return (type: string) => t(`illness.vital.${type}`);
+}
+
+function DeviationRow({ d }: { d: IllnessVitalDeviation }) {
+ const { t } = useTranslations();
+ const vitalLabel = useVitalLabel();
+ const Arrow = d.direction === "above" ? ArrowUp : ArrowDown;
+ return (
+
+
+ {vitalLabel(d.type)}
+
+
+
+ {t(`illness.correlation.direction.${d.direction}`)}
+
+
+ );
+}
+
+function CorrelationBody({ value }: { value: IllnessCorrelationValue }) {
+ const { t } = useTranslations();
+ const gap = value.recoveryGapDays;
+
+ return (
+
+ {value.redFlags.length > 0 ? (
+
+
+ {t("illness.correlation.redFlagTitle")}
+
+
+ {value.redFlags.map((f) => (
+
+ {f.reason === "sustained_low_spo2"
+ ? t("illness.correlation.redFlagSpo2")
+ : t("illness.correlation.redFlagFever")}
+
+ ))}
+
+
+ ) : null}
+
+ {gap !== null ? (
+
+
+ {t("illness.correlation.recoveryGapTitle")}
+
+
+ {gap > 0
+ ? t("illness.correlation.recoveryGapLag", { days: gap })
+ : gap < 0
+ ? t("illness.correlation.recoveryGapLead", { days: Math.abs(gap) })
+ : t("illness.correlation.recoveryGapSame")}
+
+
+ ) : null}
+
+ {value.preOnset.length > 0 ? (
+
+
+ {t("illness.correlation.preOnsetTitle")}
+
+
+ {t("illness.correlation.preOnsetBody")}
+
+
+ {value.preOnset.map((d) => (
+
+ ))}
+
+
+ ) : null}
+
+ {value.nadir.length > 0 ? (
+
+
+ {t("illness.correlation.nadirTitle")}
+
+
+ {t("illness.correlation.nadirBody")}
+
+
+ {value.nadir.map((d) => (
+
+ ))}
+
+
+ ) : null}
+
+ );
+}
+
+export function IllnessCorrelationCard({ episodeId }: { episodeId: string }) {
+ const { t } = useTranslations();
+ const { data, isLoading, isError } = useIllnessCorrelation(episodeId);
+
+ return (
+
+
+
+
+
+ {t("illness.correlation.title")}
+
+
+
+
+ {isLoading ? (
+
+
+
+
+
+ ) : isError || !data || data.status !== "ok" || !data.value ? (
+
+ {t("illness.correlation.learning")}
+
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/src/components/illness/illness-episode-detail.tsx b/src/components/illness/illness-episode-detail.tsx
new file mode 100644
index 000000000..4ad5f5e7f
--- /dev/null
+++ b/src/components/illness/illness-episode-detail.tsx
@@ -0,0 +1,142 @@
+"use client";
+
+/**
+ * v1.18.1 — per-episode detail surface (`/illness/[id]`), mirroring
+ * `/labs/[biomarkerId]`. Carries the heading + neutral status, a "log a day"
+ * entry, the stored note, an Edit affordance (the same capture sheet
+ * pre-filled), and the retrospective correlation card (recovery-gap /
+ * red-flag / pre-onset / nadir). Calm + retrospective — no prediction UI, no
+ * alarming colour.
+ */
+import { useState } from "react";
+
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import { Skeleton } from "@/components/ui/skeleton";
+import { useTranslations, useFormatters } from "@/lib/i18n/context";
+
+import { IllnessCorrelationCard } from "./illness-correlation-card";
+import { LogDaySheet } from "./log-day-sheet";
+import { NewEpisodeSheet } from "./new-episode-sheet";
+import { EpisodeMenu } from "./episode-menu";
+import { useIllnessEpisode, useResolveEpisode } from "./use-illness";
+
+/** Today as YYYY-MM-DD in the viewer's local calendar. */
+function todayLocal(): string {
+ const now = new Date();
+ const y = now.getFullYear();
+ const m = String(now.getMonth() + 1).padStart(2, "0");
+ const d = String(now.getDate()).padStart(2, "0");
+ return `${y}-${m}-${d}`;
+}
+
+export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) {
+ const { t } = useTranslations();
+ const fmt = useFormatters();
+ const { data: episode, isLoading, isError } = useIllnessEpisode(episodeId);
+ const resolve = useResolveEpisode();
+
+ const [logOpen, setLogOpen] = useState(false);
+ const [editOpen, setEditOpen] = useState(false);
+ const today = todayLocal();
+
+ if (isError) {
+ return (
+
+ {t("illness.loadError")}
+
+ );
+ }
+
+ const active = episode ? episode.resolvedAt === null : false;
+ const isChronic = episode?.lifecycle === "CHRONIC_ONGOING";
+
+ return (
+
+
+
+
+ {episode?.label ?? }
+
+ {episode ? (
+
+
+ {t(`illness.type.${episode.type}`)}
+
+
+ {active
+ ? isChronic
+ ? t("illness.status.ongoing")
+ : t("illness.status.active")
+ : t("illness.status.recovered")}
+
+
+ {t("illness.onsetOn", {
+ date: fmt.dateShort(new Date(episode.onsetAt)),
+ })}
+ {episode.resolvedAt
+ ? ` · ${t("illness.recoveredOn", {
+ date: fmt.dateShort(new Date(episode.resolvedAt)),
+ })}`
+ : ""}
+
+
+ ) : null}
+
+
+ setLogOpen(true)} className="min-h-11 sm:min-h-9">
+ {t("illness.logDay")}
+
+ {episode ? (
+ setEditOpen(true)}
+ onResolve={
+ active && !isChronic ? () => resolve.mutate(episode.id) : undefined
+ }
+ resolving={resolve.isPending}
+ />
+ ) : null}
+
+
+
+ {episode?.note ? (
+
+
+
+ {t("illness.detail.note")}
+
+
+ {episode.note}
+
+
+
+ ) : null}
+
+ {isLoading ? (
+
+ ) : (
+
+ )}
+
+ {episode ? (
+ <>
+
+
+ >
+ ) : null}
+
+ );
+}
diff --git a/src/components/illness/illness-insights-card.tsx b/src/components/illness/illness-insights-card.tsx
new file mode 100644
index 000000000..fd627f385
--- /dev/null
+++ b/src/components/illness/illness-insights-card.tsx
@@ -0,0 +1,64 @@
+"use client";
+
+/**
+ * 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.
+ */
+import { History } from "lucide-react";
+
+import { Card, CardContent, CardHeader } from "@/components/ui/card";
+import { Skeleton } from "@/components/ui/skeleton";
+import { useTranslations } from "@/lib/i18n/context";
+
+import { useIllnessInsights } from "./use-illness";
+
+export function IllnessInsightsCard({ windowDays = 365 }: { windowDays?: number }) {
+ const { t } = useTranslations();
+ const { data, isLoading, isError } = useIllnessInsights(windowDays);
+
+ return (
+
+
+
+
+
+ {t("illness.insights.title")}
+
+
+
+
+ {isLoading ? (
+
+
+
+
+ ) : isError || !data ? (
+
+ {t("illness.insights.none")}
+
+ ) : data.episodeCount === 0 ? (
+
+ {t("illness.insights.none")}
+
+ ) : (
+
+
+ {data.episodeCount === 1
+ ? t("illness.insights.countOne")
+ : t("illness.insights.countOther", { count: data.episodeCount })}
+
+
+ {data.typicalRecoveryGapDays !== null
+ ? t("illness.insights.typicalGap", {
+ days: data.typicalRecoveryGapDays,
+ })
+ : t("illness.insights.typicalGapLearning")}
+
+
+ )}
+
+
+ );
+}
diff --git a/src/components/illness/illness-view.tsx b/src/components/illness/illness-view.tsx
index deaa3c10d..890bdda9e 100644
--- a/src/components/illness/illness-view.tsx
+++ b/src/components/illness/illness-view.tsx
@@ -5,27 +5,28 @@
*
* A calm, retrospective journal: the episode history (newest first) with a
* neutral status Badge (active / recovered — never an alarming colour, the
- * med-card rule generalised), a "log a day" entry into the day-log sheet,
- * and a "mark recovered" one-tap. Retrospective-only copy: this is a
- * journal, not a medical device, and it does not diagnose. No prediction
- * UI by design.
+ * med-card rule generalised), a shared overflow kebab (Edit / Mark recovered /
+ * AlertDialog-gated Delete), a "log a day" entry, and the cross-episode
+ * retrospective summary. Each row navigates to the episode-detail surface
+ * (`/illness/[id]`) for the correlation card. Retrospective-only copy — a
+ * journal, not a medical device, and it does not diagnose.
*/
import { useState } from "react";
-import { Plus, Stethoscope } from "lucide-react";
+import Link from "next/link";
+import { ChevronRight, Plus, Stethoscope } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
-import { useTranslations } from "@/lib/i18n/context";
-import { useFormatters } from "@/lib/i18n/context";
+import { EmptyState } from "@/components/ui/empty-state";
+import { Skeleton } from "@/components/ui/skeleton";
+import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { LogDaySheet } from "./log-day-sheet";
import { NewEpisodeSheet } from "./new-episode-sheet";
-import {
- useDeleteEpisode,
- useIllnessEpisodes,
- useResolveEpisode,
-} from "./use-illness";
+import { EpisodeMenu } from "./episode-menu";
+import { IllnessInsightsCard } from "./illness-insights-card";
+import { useIllnessEpisodes, useResolveEpisode } from "./use-illness";
import type { IllnessEpisodeDTO } from "./types";
/** Today as a YYYY-MM-DD string in the viewer's local calendar. */
@@ -40,14 +41,14 @@ function todayLocal(): string {
function EpisodeRow({
episode,
onLogDay,
+ onEdit,
onResolve,
- onDelete,
resolving,
}: {
episode: IllnessEpisodeDTO;
onLogDay: (id: string) => void;
+ onEdit: (episode: IllnessEpisodeDTO) => void;
onResolve: (id: string) => void;
- onDelete: (id: string) => void;
resolving: boolean;
}) {
const { t } = useTranslations();
@@ -56,8 +57,8 @@ function EpisodeRow({
const isChronic = episode.lifecycle === "CHRONIC_ONGOING";
return (
-
-
+
+
{episode.label}
{t(`illness.type.${episode.type}`)}
@@ -77,29 +78,28 @@ function EpisodeRow({
})}`
: ""}
-
-
-
onLogDay(episode.id)}>
+
+
+ onLogDay(episode.id)}
+ >
{t("illness.logDay")}
- {active && !isChronic ? (
- onResolve(episode.id)}
- >
- {t("illness.markRecovered")}
-
- ) : null}
- onDelete(episode.id)}
- >
- {t("common.delete")}
-
+ onEdit(episode)}
+ onResolve={
+ active && !isChronic ? () => onResolve(episode.id) : undefined
+ }
+ resolving={resolving}
+ />
+
);
@@ -109,12 +109,14 @@ export function IllnessView() {
const { t } = useTranslations();
const { data: episodes, isLoading } = useIllnessEpisodes(true);
const resolve = useResolveEpisode();
- const del = useDeleteEpisode();
const today = todayLocal();
const [newOpen, setNewOpen] = useState(false);
+ const [editEpisode, setEditEpisode] = useState(null);
const [logEpisodeId, setLogEpisodeId] = useState(null);
+ const logEpisode = episodes?.find((e) => e.id === logEpisodeId) ?? null;
+
return (
@@ -138,37 +140,51 @@ export function IllnessView() {
{t("illness.disclaimer")}
{isLoading ? (
-
{t("common.loading")}
- ) : episodes && episodes.length > 0 ? (
- {episodes.map((episode) => (
- setLogEpisodeId(id)}
- onResolve={(id) => resolve.mutate(id)}
- onDelete={(id) => del.mutate(id)}
- />
- ))}
+
+
+
- ) : (
-
-
-
-
{t("illness.empty.title")}
-
- {t("illness.empty.body")}
-
+ ) : episodes && episodes.length > 0 ? (
+ <>
+
+ {episodes.map((episode) => (
+ setLogEpisodeId(id)}
+ onEdit={(e) => setEditEpisode(e)}
+ onResolve={(id) => resolve.mutate(id)}
+ />
+ ))}
-
setNewOpen(true)}>
-
- {t("illness.newEpisode")}
-
-
+
+ >
+ ) : (
+
}
+ title={t("illness.empty.title")}
+ description={t("illness.empty.body")}
+ ctaSize="lg"
+ action={
+
setNewOpen(true)}>
+
+ {t("illness.newEpisode")}
+
+ }
+ />
)}
+
{
+ if (!open) setEditEpisode(null);
+ }}
+ today={today}
+ editEpisode={editEpisode ?? undefined}
+ />
{logEpisodeId ? (
) : null}
diff --git a/src/components/illness/log-day-sheet.tsx b/src/components/illness/log-day-sheet.tsx
index d2d309f0c..4f59518ec 100644
--- a/src/components/illness/log-day-sheet.tsx
+++ b/src/components/illness/log-day-sheet.tsx
@@ -14,7 +14,7 @@
* generalised).
*/
import { useMemo, useState } from "react";
-import { Activity, NotebookPen, Thermometer } from "lucide-react";
+import { Activity, CalendarDays, NotebookPen, Thermometer } from "lucide-react";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import {
@@ -36,7 +36,14 @@ interface LogDaySheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
episodeId: string;
+ /** The day to seed the picker with (usually today). */
date: string;
+ /**
+ * Episode onset day (`YYYY-MM-DD`) — the earliest day the picker allows, so
+ * a retrospective journal can backdate a day within the episode window
+ * (a user ill Sat–Sun who opens Monday can still record either day).
+ */
+ onsetDate?: string;
}
/** Mini toggle chip — the shared selection vocabulary. */
@@ -132,9 +139,23 @@ export function LogDaySheet({
onOpenChange,
episodeId,
date,
+ onsetDate,
}: LogDaySheetProps) {
const { t } = useTranslations();
- const dayLog = useIllnessDayLog(open ? episodeId : null, date);
+
+ // The day being logged — seeded from `date` (today), but the picker lets the
+ // user backdate within [onset, today] for the retrospective journal.
+ const [selectedDate, setSelectedDate] = useState(date);
+ // Re-seed the picker each time the sheet opens (the open transition).
+ const [pickerWasOpen, setPickerWasOpen] = useState(false);
+ if (open && !pickerWasOpen) {
+ setPickerWasOpen(true);
+ setSelectedDate(date);
+ } else if (!open && pickerWasOpen) {
+ setPickerWasOpen(false);
+ }
+
+ const dayLog = useIllnessDayLog(open ? episodeId : null, selectedDate);
const upsert = useUpsertDayLog(episodeId);
const [symptoms, setSymptoms] = useState>(
@@ -150,7 +171,7 @@ export function LogDaySheet({
// `dto === null` (nothing logged) resets to blank.
const dto = open ? (dayLog.data ?? null) : undefined;
const formKey = open
- ? `${episodeId}:${date}:${dayLog.isSuccess ? "loaded" : "loading"}`
+ ? `${episodeId}:${selectedDate}:${dayLog.isSuccess ? "loaded" : "loading"}`
: null;
const [lastFormKey, setLastFormKey] = useState(null);
if (open && formKey !== lastFormKey && !dayLog.isLoading) {
@@ -192,7 +213,7 @@ export function LogDaySheet({
async function handleSave() {
const input: IllnessDayLogInput = {
- date,
+ date: selectedDate,
functionalImpact,
feverC: feverValue,
symptoms: Array.from(symptoms.entries()).map(([key, severity]) => ({
@@ -220,7 +241,7 @@ export function LogDaySheet({
@@ -234,6 +255,32 @@ export function LogDaySheet({
}
>
+
}
+ summary={
+
{selectedDate}
+ }
+ defaultOpen
+ >
+
+
+ {t("illness.sheet.day")}
+
+ {
+ if (e.target.value) setSelectedDate(e.target.value);
+ }}
+ className="max-w-44"
+ />
+
+
+
}
diff --git a/src/components/illness/new-episode-sheet.tsx b/src/components/illness/new-episode-sheet.tsx
index adfc44189..b312d6512 100644
--- a/src/components/illness/new-episode-sheet.tsx
+++ b/src/components/illness/new-episode-sheet.tsx
@@ -22,8 +22,12 @@ import {
} from "@/components/ui/select";
import { useTranslations } from "@/lib/i18n/context";
-import { useCreateEpisode } from "./use-illness";
-import type { IllnessLifecycle, IllnessType } from "./types";
+import { useCreateEpisode, useIllnessEpisodes, useUpdateEpisode } from "./use-illness";
+import type {
+ IllnessEpisodeDTO,
+ IllnessLifecycle,
+ IllnessType,
+} from "./types";
const TYPES: IllnessType[] = [
"INFECTION",
@@ -39,55 +43,95 @@ const LIFECYCLES: IllnessLifecycle[] = [
"ACUTE",
"CHRONIC_ONGOING",
"RECURRING",
+ "FLARE",
];
+/** Lifecycles that may hang off a parent condition. */
+const PARENTABLE: IllnessLifecycle[] = ["FLARE", "RECURRING"];
+
+const NONE = "__none__";
+
interface NewEpisodeSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
today: string;
+ /** When set, the sheet edits this episode instead of creating a new one. */
+ editEpisode?: IllnessEpisodeDTO;
}
export function NewEpisodeSheet({
open,
onOpenChange,
today,
+ editEpisode,
}: NewEpisodeSheetProps) {
const { t } = useTranslations();
const create = useCreateEpisode();
+ const update = useUpdateEpisode();
+ const isEdit = editEpisode !== undefined;
+ const pending = create.isPending || update.isPending;
+ const isError = create.isError || update.isError;
+
+ // Candidate parents: live episodes other than the one being edited.
+ const { data: allEpisodes } = useIllnessEpisodes(true);
+ const parentCandidates = (allEpisodes ?? []).filter(
+ (e) => e.id !== editEpisode?.id,
+ );
const [label, setLabel] = useState("");
const [type, setType] = useState
("INFECTION");
const [lifecycle, setLifecycle] = useState("ACUTE");
const [onset, setOnset] = useState(today);
+ const [parentId, setParentId] = useState(NONE);
const [note, setNote] = useState("");
- // Reset the form each time the sheet opens — adjusted during render keyed
- // on the open transition (React's recommended alternative to a
+ // Reset / hydrate the form each time the sheet opens — adjusted during
+ // render keyed on the open transition (React's recommended alternative to a
// setState-in-effect).
const [wasOpen, setWasOpen] = useState(false);
if (open && !wasOpen) {
setWasOpen(true);
- setLabel("");
- setType("INFECTION");
- setLifecycle("ACUTE");
- setOnset(today);
- setNote("");
+ setLabel(editEpisode?.label ?? "");
+ setType(editEpisode?.type ?? "INFECTION");
+ setLifecycle(editEpisode?.lifecycle ?? "ACUTE");
+ setOnset(editEpisode ? editEpisode.onsetAt.slice(0, 10) : today);
+ setParentId(editEpisode?.parentConditionId ?? NONE);
+ setNote(editEpisode?.note ?? "");
} else if (!open && wasOpen) {
setWasOpen(false);
}
+ const showParent = PARENTABLE.includes(lifecycle);
+
async function handleSave() {
if (label.trim() === "") return;
+ const onsetAt = new Date(`${onset}T12:00:00`).toISOString();
+ const parentConditionId = showParent && parentId !== NONE ? parentId : null;
try {
- await create.mutateAsync({
- label: label.trim(),
- type,
- lifecycle,
- // Anchor the date string to local noon so the UTC instant lands on
- // the intended calendar day regardless of the viewer's offset.
- onsetAt: new Date(`${onset}T12:00:00`).toISOString(),
- note: note.trim() === "" ? null : note,
- });
+ if (isEdit && editEpisode) {
+ await update.mutateAsync({
+ id: editEpisode.id,
+ input: {
+ label: label.trim(),
+ type,
+ lifecycle,
+ onsetAt,
+ parentConditionId,
+ note: note.trim() === "" ? null : note,
+ },
+ });
+ } else {
+ await create.mutateAsync({
+ label: label.trim(),
+ type,
+ lifecycle,
+ // Anchor to local noon so the UTC instant lands on the intended
+ // calendar day regardless of the viewer's offset.
+ onsetAt,
+ parentConditionId,
+ note: note.trim() === "" ? null : note,
+ });
+ }
onOpenChange(false);
} catch {
// Keep the sheet open; the error strip reports it.
@@ -98,17 +142,16 @@ export function NewEpisodeSheet({
onOpenChange(false)}>
{t("common.cancel")}
-
+
{t("common.save")}
>
@@ -161,6 +204,25 @@ export function NewEpisodeSheet({
+ {showParent ? (
+
+ {t("illness.new.parent")}
+
+
+
+
+
+ {t("illness.new.parentNone")}
+ {parentCandidates.map((e) => (
+
+ {e.label}
+
+ ))}
+
+
+
+ ) : null}
+
{t("illness.new.onset")}
- {create.isError ? (
+ {isError ? (
{t("illness.new.saveError")}
diff --git a/src/components/illness/types.ts b/src/components/illness/types.ts
index bbf59f652..04ee34c1a 100644
--- a/src/components/illness/types.ts
+++ b/src/components/illness/types.ts
@@ -52,6 +52,7 @@ export interface IllnessEpisodeCreateInput {
type: IllnessType;
lifecycle?: IllnessLifecycle;
onsetAt?: string;
+ parentConditionId?: string | null;
note?: string | null;
}
@@ -62,3 +63,79 @@ export interface IllnessDayLogInput {
symptoms?: IllnessSymptomSelection[];
note?: string | null;
}
+
+export interface IllnessEpisodeUpdateInput {
+ label?: string;
+ type?: IllnessType;
+ lifecycle?: IllnessLifecycle;
+ onsetAt?: string;
+ resolvedAt?: string | null;
+ parentConditionId?: string | null;
+ note?: string | null;
+}
+
+/* ── P3 correlation + retrospective DTOs (server-authoritative) ───────── */
+
+export interface IllnessVitalDeviation {
+ type: string;
+ day: string;
+ value: number;
+ baselineCenter: number;
+ deviationSd: number;
+ direction: "above" | "below";
+ adverse: boolean;
+}
+
+export interface IllnessVitalReturn {
+ type: string;
+ returnedDay: string | null;
+ gapDays: number | null;
+}
+
+export interface IllnessRedFlag {
+ type: string;
+ reason: "sustained_low_spo2" | "sustained_fever";
+ worstValue: number;
+ days: number;
+}
+
+export interface IllnessCorrelationValue {
+ episodeId: string;
+ preOnset: IllnessVitalDeviation[];
+ nadir: IllnessVitalDeviation[];
+ returns: IllnessVitalReturn[];
+ recoveryGapDays: number | null;
+ feltBetterDay: string | null;
+ redFlags: IllnessRedFlag[];
+}
+
+/** The flat `Derived` wire shape the correlation route returns. */
+export interface IllnessCorrelationResponse {
+ episodeId: string;
+ status: "ok" | "insufficient";
+ value: IllnessCorrelationValue | null;
+ coverage: {
+ requiredInputs: number;
+ presentInputs: number;
+ historyDays: number;
+ missing: string[];
+ };
+ confidence: { score: number; band: string } | null;
+ provenance: {
+ inputs: string[];
+ source: string;
+ windowDays: number;
+ computedAt: string;
+ };
+ reason: string | null;
+}
+
+export interface IllnessInsightsResponse {
+ windowDays: number;
+ episodeCount: number;
+ resolvedCount: number;
+ typicalRecoveryGapDays: number | null;
+ gapSampleSize: number;
+ byMonth: Record;
+ byType: Record;
+}
diff --git a/src/components/illness/use-illness.ts b/src/components/illness/use-illness.ts
index 6cff6a0bb..613734720 100644
--- a/src/components/illness/use-illness.ts
+++ b/src/components/illness/use-illness.ts
@@ -14,10 +14,13 @@ import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import type {
+ IllnessCorrelationResponse,
IllnessDayLogDTO,
IllnessDayLogInput,
IllnessEpisodeCreateInput,
IllnessEpisodeDTO,
+ IllnessEpisodeUpdateInput,
+ IllnessInsightsResponse,
} from "./types";
/** Newest-first episode list. */
@@ -31,6 +34,43 @@ export function useIllnessEpisodes(includeResolved = true) {
});
}
+/** One episode by id. */
+export function useIllnessEpisode(id: string | null) {
+ return useQuery({
+ queryKey: queryKeys.illnessEpisode(id ?? "none"),
+ enabled: id !== null,
+ queryFn: () => apiGet(`/api/illness/episodes/${id}`),
+ });
+}
+
+/**
+ * The per-episode retrospective correlation (recovery-gap / red-flag /
+ * pre-onset / nadir). Server-authoritative + coverage-gated — the surface
+ * pattern-matches `status` ("insufficient" → "still learning"), never
+ * recomputes.
+ */
+export function useIllnessCorrelation(episodeId: string | null) {
+ return useQuery({
+ queryKey: queryKeys.illnessCorrelation(episodeId ?? "none"),
+ enabled: episodeId !== null,
+ queryFn: () =>
+ apiGet(
+ `/api/illness/episodes/${episodeId}/correlation`,
+ ),
+ });
+}
+
+/** The cross-episode retrospective summary over a trailing window in days. */
+export function useIllnessInsights(windowDays = 365) {
+ return useQuery({
+ queryKey: queryKeys.illnessInsights(windowDays),
+ queryFn: () =>
+ apiGet(
+ `/api/illness/insights?windowDays=${windowDays}`,
+ ),
+ });
+}
+
/** One episode's day-log for a given date (null when nothing is logged). */
export function useIllnessDayLog(episodeId: string | null, date: string) {
return useQuery({
@@ -58,6 +98,16 @@ export function useCreateEpisode() {
});
}
+/** Edit an episode (partial). */
+export function useUpdateEpisode() {
+ const invalidate = useInvalidateIllness();
+ return useMutation({
+ mutationFn: ({ id, input }: { id: string; input: IllnessEpisodeUpdateInput }) =>
+ apiPatch(`/api/illness/episodes/${id}`, input),
+ onSuccess: invalidate,
+ });
+}
+
/** Mark an episode recovered. */
export function useResolveEpisode() {
const invalidate = useInvalidateIllness();