mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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.
This commit is contained in:
1 parent
d7a698950c
commit
c243f6a963
16 files changed
+1092
-105
No files matched your search
+34
-2
@@ -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": {
|
||||
|
||||
+34
-2
@@ -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": {
|
||||
|
||||
+34
-2
@@ -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": {
|
||||
|
||||
+34
-2
@@ -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": {
|
||||
|
||||
+34
-2
@@ -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": {
|
||||
|
||||
+34
-2
@@ -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": {
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<BackLink href="/illness" label={t("illness.backToList")} />
|
||||
<IllnessEpisodeDetail episodeId={id} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11 sm:size-9"
|
||||
aria-label={t("illness.menu.label")}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onSelect={onEdit}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
{t("common.edit")}
|
||||
</DropdownMenuItem>
|
||||
{onResolve ? (
|
||||
<DropdownMenuItem onSelect={onResolve} disabled={resolving}>
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
{t("illness.markRecovered")}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
setConfirmOpen(true);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
{t("common.delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("illness.deleteConfirm.title")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("illness.deleteConfirm.body", { label: episode.label })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmDelete}
|
||||
disabled={del.isPending}
|
||||
>
|
||||
{t("common.delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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<T>` `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 (
|
||||
<li className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="text-foreground min-w-0 truncate">
|
||||
{vitalLabel(d.type)}
|
||||
</span>
|
||||
<span className="text-muted-foreground flex shrink-0 items-center gap-1 tabular-nums">
|
||||
<Arrow className="h-3.5 w-3.5" aria-hidden />
|
||||
{t(`illness.correlation.direction.${d.direction}`)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function CorrelationBody({ value }: { value: IllnessCorrelationValue }) {
|
||||
const { t } = useTranslations();
|
||||
const gap = value.recoveryGapDays;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{value.redFlags.length > 0 ? (
|
||||
<div className="border-border bg-muted/40 rounded-lg border p-3">
|
||||
<p className="text-foreground text-sm font-medium">
|
||||
{t("illness.correlation.redFlagTitle")}
|
||||
</p>
|
||||
<ul className="text-muted-foreground mt-1 space-y-1 text-sm">
|
||||
{value.redFlags.map((f) => (
|
||||
<li key={`${f.type}:${f.reason}`}>
|
||||
{f.reason === "sustained_low_spo2"
|
||||
? t("illness.correlation.redFlagSpo2")
|
||||
: t("illness.correlation.redFlagFever")}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{gap !== null ? (
|
||||
<div>
|
||||
<p className="text-foreground text-sm font-medium">
|
||||
{t("illness.correlation.recoveryGapTitle")}
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
{gap > 0
|
||||
? t("illness.correlation.recoveryGapLag", { days: gap })
|
||||
: gap < 0
|
||||
? t("illness.correlation.recoveryGapLead", { days: Math.abs(gap) })
|
||||
: t("illness.correlation.recoveryGapSame")}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{value.preOnset.length > 0 ? (
|
||||
<div>
|
||||
<p className="text-foreground text-sm font-medium">
|
||||
{t("illness.correlation.preOnsetTitle")}
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1 mb-2 text-xs">
|
||||
{t("illness.correlation.preOnsetBody")}
|
||||
</p>
|
||||
<ul className="space-y-1.5">
|
||||
{value.preOnset.map((d) => (
|
||||
<DeviationRow key={`pre:${d.type}`} d={d} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{value.nadir.length > 0 ? (
|
||||
<div>
|
||||
<p className="text-foreground text-sm font-medium">
|
||||
{t("illness.correlation.nadirTitle")}
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1 mb-2 text-xs">
|
||||
{t("illness.correlation.nadirBody")}
|
||||
</p>
|
||||
<ul className="space-y-1.5">
|
||||
{value.nadir.map((d) => (
|
||||
<DeviationRow key={`nadir:${d.type}`} d={d} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function IllnessCorrelationCard({ episodeId }: { episodeId: string }) {
|
||||
const { t } = useTranslations();
|
||||
const { data, isLoading, isError } = useIllnessCorrelation(episodeId);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Info className="text-muted-foreground h-4 w-4" aria-hidden />
|
||||
<h2 className="text-base font-semibold">
|
||||
{t("illness.correlation.title")}
|
||||
</h2>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
</div>
|
||||
) : isError || !data || data.status !== "ok" || !data.value ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("illness.correlation.learning")}
|
||||
</p>
|
||||
) : (
|
||||
<CorrelationBody value={data.value} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<p className="text-destructive py-8 text-center text-sm">
|
||||
{t("illness.loadError")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const active = episode ? episode.resolvedAt === null : false;
|
||||
const isChronic = episode?.lifecycle === "CHRONIC_ONGOING";
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h1 className="truncate text-2xl font-bold tracking-tight">
|
||||
{episode?.label ?? <Skeleton className="h-7 w-40" />}
|
||||
</h1>
|
||||
{episode ? (
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary">
|
||||
{t(`illness.type.${episode.type}`)}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
{active
|
||||
? isChronic
|
||||
? t("illness.status.ongoing")
|
||||
: t("illness.status.active")
|
||||
: t("illness.status.recovered")}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("illness.onsetOn", {
|
||||
date: fmt.dateShort(new Date(episode.onsetAt)),
|
||||
})}
|
||||
{episode.resolvedAt
|
||||
? ` · ${t("illness.recoveredOn", {
|
||||
date: fmt.dateShort(new Date(episode.resolvedAt)),
|
||||
})}`
|
||||
: ""}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button onClick={() => setLogOpen(true)} className="min-h-11 sm:min-h-9">
|
||||
{t("illness.logDay")}
|
||||
</Button>
|
||||
{episode ? (
|
||||
<EpisodeMenu
|
||||
episode={episode}
|
||||
onEdit={() => setEditOpen(true)}
|
||||
onResolve={
|
||||
active && !isChronic ? () => resolve.mutate(episode.id) : undefined
|
||||
}
|
||||
resolving={resolve.isPending}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{episode?.note ? (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
|
||||
{t("illness.detail.note")}
|
||||
</p>
|
||||
<p className="text-foreground mt-1 text-sm whitespace-pre-wrap">
|
||||
{episode.note}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-40 w-full" />
|
||||
) : (
|
||||
<IllnessCorrelationCard episodeId={episodeId} />
|
||||
)}
|
||||
|
||||
{episode ? (
|
||||
<>
|
||||
<LogDaySheet
|
||||
open={logOpen}
|
||||
onOpenChange={setLogOpen}
|
||||
episodeId={episode.id}
|
||||
date={today}
|
||||
onsetDate={episode.onsetAt.slice(0, 10)}
|
||||
/>
|
||||
<NewEpisodeSheet
|
||||
open={editOpen}
|
||||
onOpenChange={setEditOpen}
|
||||
today={today}
|
||||
editEpisode={episode}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<History className="text-muted-foreground h-4 w-4" aria-hidden />
|
||||
<h2 className="text-base font-semibold">
|
||||
{t("illness.insights.title")}
|
||||
</h2>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{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 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("illness.insights.none")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2 text-sm">
|
||||
<p className="text-foreground">
|
||||
{data.episodeCount === 1
|
||||
? t("illness.insights.countOne")
|
||||
: t("illness.insights.countOther", { count: data.episodeCount })}
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
{data.typicalRecoveryGapDays !== null
|
||||
? t("illness.insights.typicalGap", {
|
||||
days: data.typicalRecoveryGapDays,
|
||||
})
|
||||
: t("illness.insights.typicalGapLearning")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Card className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<Card className="flex items-center gap-3 p-4">
|
||||
<Link href={`/illness/${episode.id}`} className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-medium">{episode.label}</span>
|
||||
<Badge variant="secondary">{t(`illness.type.${episode.type}`)}</Badge>
|
||||
@@ -77,29 +78,28 @@ function EpisodeRow({
|
||||
})}`
|
||||
: ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => onLogDay(episode.id)}>
|
||||
</Link>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => onLogDay(episode.id)}
|
||||
>
|
||||
{t("illness.logDay")}
|
||||
</Button>
|
||||
{active && !isChronic ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={resolving}
|
||||
onClick={() => onResolve(episode.id)}
|
||||
>
|
||||
{t("illness.markRecovered")}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground"
|
||||
onClick={() => onDelete(episode.id)}
|
||||
>
|
||||
{t("common.delete")}
|
||||
</Button>
|
||||
<EpisodeMenu
|
||||
episode={episode}
|
||||
onEdit={() => onEdit(episode)}
|
||||
onResolve={
|
||||
active && !isChronic ? () => onResolve(episode.id) : undefined
|
||||
}
|
||||
resolving={resolving}
|
||||
/>
|
||||
<ChevronRight
|
||||
className="text-muted-foreground hidden h-4 w-4 sm:block"
|
||||
aria-hidden
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
@@ -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<IllnessEpisodeDTO | null>(null);
|
||||
const [logEpisodeId, setLogEpisodeId] = useState<string | null>(null);
|
||||
|
||||
const logEpisode = episodes?.find((e) => e.id === logEpisodeId) ?? null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
@@ -138,37 +140,51 @@ export function IllnessView() {
|
||||
<p className="text-muted-foreground text-xs">{t("illness.disclaimer")}</p>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-muted-foreground text-sm">{t("common.loading")}</p>
|
||||
) : episodes && episodes.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{episodes.map((episode) => (
|
||||
<EpisodeRow
|
||||
key={episode.id}
|
||||
episode={episode}
|
||||
resolving={resolve.isPending}
|
||||
onLogDay={(id) => setLogEpisodeId(id)}
|
||||
onResolve={(id) => resolve.mutate(id)}
|
||||
onDelete={(id) => del.mutate(id)}
|
||||
/>
|
||||
))}
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
</div>
|
||||
) : (
|
||||
<Card className="flex flex-col items-center gap-3 p-10 text-center">
|
||||
<Stethoscope className="text-muted-foreground h-8 w-8" />
|
||||
<div>
|
||||
<p className="font-medium">{t("illness.empty.title")}</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("illness.empty.body")}
|
||||
</p>
|
||||
) : episodes && episodes.length > 0 ? (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
{episodes.map((episode) => (
|
||||
<EpisodeRow
|
||||
key={episode.id}
|
||||
episode={episode}
|
||||
resolving={resolve.isPending}
|
||||
onLogDay={(id) => setLogEpisodeId(id)}
|
||||
onEdit={(e) => setEditEpisode(e)}
|
||||
onResolve={(id) => resolve.mutate(id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => setNewOpen(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("illness.newEpisode")}
|
||||
</Button>
|
||||
</Card>
|
||||
<IllnessInsightsCard />
|
||||
</>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={<Stethoscope className="size-6" />}
|
||||
title={t("illness.empty.title")}
|
||||
description={t("illness.empty.body")}
|
||||
ctaSize="lg"
|
||||
action={
|
||||
<Button onClick={() => setNewOpen(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("illness.newEpisode")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<NewEpisodeSheet open={newOpen} onOpenChange={setNewOpen} today={today} />
|
||||
<NewEpisodeSheet
|
||||
open={editEpisode !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setEditEpisode(null);
|
||||
}}
|
||||
today={today}
|
||||
editEpisode={editEpisode ?? undefined}
|
||||
/>
|
||||
{logEpisodeId ? (
|
||||
<LogDaySheet
|
||||
open={logEpisodeId !== null}
|
||||
@@ -177,6 +193,7 @@ export function IllnessView() {
|
||||
}}
|
||||
episodeId={logEpisodeId}
|
||||
date={today}
|
||||
onsetDate={logEpisode?.onsetAt.slice(0, 10)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -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<Map<string, number | null>>(
|
||||
@@ -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<string | null>(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({
|
||||
<ResponsiveSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t("illness.sheet.title", { date })}
|
||||
title={t("illness.sheet.title", { date: selectedDate })}
|
||||
description={t("illness.sheet.description")}
|
||||
footer={
|
||||
<>
|
||||
@@ -234,6 +255,32 @@ export function LogDaySheet({
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<SheetSection
|
||||
title={t("illness.sheet.day")}
|
||||
icon={<CalendarDays className="h-4 w-4" />}
|
||||
summary={
|
||||
<span className="text-muted-foreground text-xs">{selectedDate}</span>
|
||||
}
|
||||
defaultOpen
|
||||
>
|
||||
<div className="pt-2">
|
||||
<Label htmlFor="illness-log-date" className="sr-only">
|
||||
{t("illness.sheet.day")}
|
||||
</Label>
|
||||
<Input
|
||||
id="illness-log-date"
|
||||
type="date"
|
||||
value={selectedDate}
|
||||
min={onsetDate}
|
||||
max={date}
|
||||
onChange={(e) => {
|
||||
if (e.target.value) setSelectedDate(e.target.value);
|
||||
}}
|
||||
className="max-w-44"
|
||||
/>
|
||||
</div>
|
||||
</SheetSection>
|
||||
|
||||
<SheetSection
|
||||
title={t("illness.sheet.symptoms")}
|
||||
icon={<Activity className="h-4 w-4" />}
|
||||
|
||||
@@ -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<IllnessType>("INFECTION");
|
||||
const [lifecycle, setLifecycle] = useState<IllnessLifecycle>("ACUTE");
|
||||
const [onset, setOnset] = useState(today);
|
||||
const [parentId, setParentId] = useState<string>(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({
|
||||
<ResponsiveSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t("illness.new.title")}
|
||||
description={t("illness.new.description")}
|
||||
title={isEdit ? t("illness.edit.title") : t("illness.new.title")}
|
||||
description={
|
||||
isEdit ? t("illness.edit.description") : t("illness.new.description")
|
||||
}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={create.isPending || label.trim() === ""}
|
||||
>
|
||||
<Button onClick={handleSave} disabled={pending || label.trim() === ""}>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</>
|
||||
@@ -161,6 +204,25 @@ export function NewEpisodeSheet({
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{showParent ? (
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("illness.new.parent")}</Label>
|
||||
<Select value={parentId} onValueChange={setParentId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("illness.new.parentNone")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE}>{t("illness.new.parentNone")}</SelectItem>
|
||||
{parentCandidates.map((e) => (
|
||||
<SelectItem key={e.id} value={e.id}>
|
||||
{e.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="illness-onset">{t("illness.new.onset")}</Label>
|
||||
<Input
|
||||
@@ -185,7 +247,7 @@ export function NewEpisodeSheet({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{create.isError ? (
|
||||
{isError ? (
|
||||
<p className="text-destructive text-sm" role="alert">
|
||||
{t("illness.new.saveError")}
|
||||
</p>
|
||||
|
||||
@@ -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<T>` 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<string, number>;
|
||||
byType: Record<string, number>;
|
||||
}
|
||||
@@ -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<IllnessEpisodeDTO>(`/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<IllnessCorrelationResponse>(
|
||||
`/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<IllnessInsightsResponse>(
|
||||
`/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<IllnessEpisodeDTO>(`/api/illness/episodes/${id}`, input),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
/** Mark an episode recovered. */
|
||||
export function useResolveEpisode() {
|
||||
const invalidate = useInvalidateIllness();
|
||||
|
||||
Reference in new issue
Block a user