From c243f6a963bd8c9759078ddcecba0656a7bee90e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 16 Jun 2026 23:18:06 +0200 Subject: [PATCH] feat(illness): episode detail, retrospective surface, edit + delete guard Add an episode-detail page at /illness/[id] mirroring the lab-biomarker detail: it renders the per-episode correlation card (recovery gap, red flags, pre-onset and nadir findings, or 'still learning' when the signal is thin) and surfaces the stored note. List rows link into it; the cross-episode retrospective summary sits below the history. Fold the row's inline buttons into a shared overflow menu and gate delete behind a confirmation dialog. Let the new-episode sheet double as an edit form, add the FLARE course and a parent-condition picker, and let the day-log sheet backdate within the episode window. Swap the loading and empty states onto the shared primitives. --- messages/de.json | 36 ++++- messages/en.json | 36 ++++- messages/es.json | 36 ++++- messages/fr.json | 36 ++++- messages/it.json | 36 ++++- messages/pl.json | 36 ++++- src/app/illness/[id]/page.tsx | 56 +++++++ src/components/illness/episode-menu.tsx | 129 +++++++++++++++ .../illness/illness-correlation-card.tsx | 151 ++++++++++++++++++ .../illness/illness-episode-detail.tsx | 142 ++++++++++++++++ .../illness/illness-insights-card.tsx | 64 ++++++++ src/components/illness/illness-view.tsx | 143 +++++++++-------- src/components/illness/log-day-sheet.tsx | 57 ++++++- src/components/illness/new-episode-sheet.tsx | 112 ++++++++++--- src/components/illness/types.ts | 77 +++++++++ src/components/illness/use-illness.ts | 50 ++++++ 16 files changed, 1092 insertions(+), 105 deletions(-) create mode 100644 src/app/illness/[id]/page.tsx create mode 100644 src/components/illness/episode-menu.tsx create mode 100644 src/components/illness/illness-correlation-card.tsx create mode 100644 src/components/illness/illness-episode-detail.tsx create mode 100644 src/components/illness/illness-insights-card.tsx 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 ( + <> + + + + + + + + {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} +
    +
    + + {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({ })}` : ""}

    -
    -
    - - {active && !isChronic ? ( - - ) : null} - + 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)} + /> + ))}
    - - + + + ) : ( + } + title={t("illness.empty.title")} + description={t("illness.empty.body")} + ctaSize="lg" + action={ + + } + /> )} + { + 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 + > +
    + + { + 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({ - @@ -161,6 +204,25 @@ export function NewEpisodeSheet({
    + {showParent ? ( +
    + + +
    + ) : null} +
    - {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();