diff --git a/messages/de.json b/messages/de.json index 35477aa3c..fdc712ad2 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6791,6 +6791,24 @@ "byMonth": "Wann du Episoden erfasst", "none": "In diesem Zeitraum keine Episoden erfasst." }, + "section": { + "active": "Aktiv", + "resolved": "Abgeschlossen" + }, + "flares": { + "countOne": "1 Schub", + "countOther": "{count} Schübe" + }, + "timeline": { + "title": "Tagesverlauf", + "error": "Der heutige Eintrag konnte nicht geladen werden.", + "emptyToday": "Heute noch nichts erfasst.", + "today": "Heute · {date}", + "severity": "Stärke {severity}", + "noSymptoms": "Keine Symptome erfasst.", + "impact": "Befinden: {impact}", + "fever": "Temperatur: {value} °C" + }, "loadError": "Could not load this episode.", "backToList": "Back to journal", "detail": { diff --git a/messages/en.json b/messages/en.json index b651f7a98..3c9a8efb3 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6791,6 +6791,24 @@ "byMonth": "When you tend to log episodes", "none": "No episodes logged in this period." }, + "section": { + "active": "Active", + "resolved": "Resolved" + }, + "flares": { + "countOne": "1 flare", + "countOther": "{count} flares" + }, + "timeline": { + "title": "Day timeline", + "error": "Could not load today's entry.", + "emptyToday": "Nothing logged today yet.", + "today": "Today · {date}", + "severity": "Severity {severity}", + "noSymptoms": "No symptoms logged.", + "impact": "How you functioned: {impact}", + "fever": "Temperature: {value} °C" + }, "loadError": "Could not load this episode.", "backToList": "Back to journal", "detail": { diff --git a/messages/es.json b/messages/es.json index 53f0e1d22..240620c60 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6791,6 +6791,24 @@ "byMonth": "Cuándo sueles registrar episodios", "none": "Sin episodios registrados en este período." }, + "section": { + "active": "Activos", + "resolved": "Resueltos" + }, + "flares": { + "countOne": "1 brote", + "countOther": "{count} brotes" + }, + "timeline": { + "title": "Cronología del día", + "error": "No se pudo cargar el registro de hoy.", + "emptyToday": "Aún no hay nada registrado hoy.", + "today": "Hoy · {date}", + "severity": "Intensidad {severity}", + "noSymptoms": "Sin síntomas registrados.", + "impact": "Cómo funcionaste: {impact}", + "fever": "Temperatura: {value} °C" + }, "loadError": "Could not load this episode.", "backToList": "Back to journal", "detail": { diff --git a/messages/fr.json b/messages/fr.json index a148aa1c1..a628cf66e 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6791,6 +6791,24 @@ "byMonth": "Quand tu enregistres des épisodes", "none": "Aucun épisode enregistré sur cette période." }, + "section": { + "active": "Actifs", + "resolved": "Résolus" + }, + "flares": { + "countOne": "1 poussée", + "countOther": "{count} poussées" + }, + "timeline": { + "title": "Chronologie du jour", + "error": "Impossible de charger l'entrée du jour.", + "emptyToday": "Rien d'enregistré aujourd'hui pour l'instant.", + "today": "Aujourd'hui · {date}", + "severity": "Intensité {severity}", + "noSymptoms": "Aucun symptôme enregistré.", + "impact": "Votre forme : {impact}", + "fever": "Température : {value} °C" + }, "loadError": "Could not load this episode.", "backToList": "Back to journal", "detail": { diff --git a/messages/it.json b/messages/it.json index b7d55971b..21baf7519 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6791,6 +6791,24 @@ "byMonth": "Quando tendi a registrare episodi", "none": "Nessun episodio registrato in questo periodo." }, + "section": { + "active": "Attivi", + "resolved": "Risolti" + }, + "flares": { + "countOne": "1 riacutizzazione", + "countOther": "{count} riacutizzazioni" + }, + "timeline": { + "title": "Cronologia del giorno", + "error": "Impossibile caricare il diario di oggi.", + "emptyToday": "Oggi non hai ancora registrato nulla.", + "today": "Oggi · {date}", + "severity": "Intensità {severity}", + "noSymptoms": "Nessun sintomo registrato.", + "impact": "Come ti sei sentito: {impact}", + "fever": "Temperatura: {value} °C" + }, "loadError": "Could not load this episode.", "backToList": "Back to journal", "detail": { diff --git a/messages/pl.json b/messages/pl.json index 20bda86fc..a9302ce93 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6791,6 +6791,24 @@ "byMonth": "Kiedy zwykle zapisujesz epizody", "none": "Brak epizodów zapisanych w tym okresie." }, + "section": { + "active": "Aktywne", + "resolved": "Zakończone" + }, + "flares": { + "countOne": "1 zaostrzenie", + "countOther": "Zaostrzenia: {count}" + }, + "timeline": { + "title": "Oś dnia", + "error": "Nie udało się wczytać dzisiejszego wpisu.", + "emptyToday": "Dziś nic jeszcze nie zapisano.", + "today": "Dziś · {date}", + "severity": "Nasilenie {severity}", + "noSymptoms": "Nie zapisano objawów.", + "impact": "Jak funkcjonowałeś: {impact}", + "fever": "Temperatura: {value} °C" + }, "loadError": "Could not load this episode.", "backToList": "Back to journal", "detail": { diff --git a/src/components/illness/illness-day-timeline.tsx b/src/components/illness/illness-day-timeline.tsx new file mode 100644 index 000000000..291ddb2bf --- /dev/null +++ b/src/components/illness/illness-day-timeline.tsx @@ -0,0 +1,127 @@ +"use client"; + +/** + * v1.18.2 — the per-day timeline anchor, hosted exclusively on the episode + * detail surface. It surfaces today's logged day (symptoms with their 0–3 + * severity, functional impact, fever) so the detail page owns the day-log + * content the list rows no longer carry. Retrospective only — a record of + * what was logged, never a forecast. Neutral palette throughout. + * + * The read uses the single-day `useIllnessDayLog` hook (the only day-log read + * the API exposes); when nothing is logged for the day it renders a calm + * prompt to log it. + */ +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useTranslations, useFormatters } from "@/lib/i18n/context"; + +import { ILLNESS_SYMPTOM_CATALOG } from "./symptom-catalog"; +import { useIllnessDayLog } from "./use-illness"; + +function symptomLabelKey(key: string): string | null { + return ILLNESS_SYMPTOM_CATALOG.find((s) => s.key === key)?.labelKey ?? null; +} + +export function IllnessDayTimeline({ + episodeId, + date, + onLogDay, +}: { + episodeId: string; + date: string; + onLogDay: () => void; +}) { + const { t } = useTranslations(); + const fmt = useFormatters(); + const { data, isLoading, isError } = useIllnessDayLog(episodeId, date); + + return ( + + +

+ {t("illness.timeline.title")} +

+
+ + {isLoading ? ( +
+ + +
+ ) : isError ? ( +

+ {t("illness.timeline.error")} +

+ ) : !data ? ( +
+

+ {t("illness.timeline.emptyToday")} +

+ +
+ ) : ( +
+

+ {t("illness.timeline.today", { + date: fmt.dateShort(`${data.date}T12:00:00`), + })} +

+ + {data.symptoms.length > 0 ? ( +
+ {data.symptoms.map((s) => { + const labelKey = symptomLabelKey(s.key); + if (!labelKey) return null; + return ( + + {t(labelKey)} + {typeof s.severity === "number" + ? ` · ${t("illness.timeline.severity", { + severity: s.severity, + })}` + : ""} + + ); + })} +
+ ) : ( +

+ {t("illness.timeline.noSymptoms")} +

+ )} + + {data.functionalImpact !== null ? ( +

+ {t("illness.timeline.impact", { + impact: t(`illness.impact.${data.functionalImpact}`), + })} +

+ ) : null} + + {data.feverC !== null ? ( +

+ {t("illness.timeline.fever", { + value: fmt.number(data.feverC, 1), + })} +

+ ) : null} + + {data.note ? ( +

+ {data.note} +

+ ) : null} +
+ )} +
+
+ ); +} diff --git a/src/components/illness/illness-episode-detail.tsx b/src/components/illness/illness-episode-detail.tsx index 4ad5f5e7f..e4262edb6 100644 --- a/src/components/illness/illness-episode-detail.tsx +++ b/src/components/illness/illness-episode-detail.tsx @@ -17,6 +17,7 @@ import { Skeleton } from "@/components/ui/skeleton"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { IllnessCorrelationCard } from "./illness-correlation-card"; +import { IllnessDayTimeline } from "./illness-day-timeline"; import { LogDaySheet } from "./log-day-sheet"; import { NewEpisodeSheet } from "./new-episode-sheet"; import { EpisodeMenu } from "./episode-menu"; @@ -114,6 +115,17 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) { ) : null} + {/* The per-day timeline lives only on the detail surface — the list + rows stay clean summaries, so navigating here reveals genuinely new + content (today's logged symptoms / impact / fever). */} + {episode ? ( + setLogOpen(true)} + /> + ) : null} + {isLoading ? ( ) : ( diff --git a/src/components/illness/illness-view.tsx b/src/components/illness/illness-view.tsx index 890bdda9e..88d533285 100644 --- a/src/components/illness/illness-view.tsx +++ b/src/components/illness/illness-view.tsx @@ -1,25 +1,36 @@ "use client"; /** - * v1.18.1 — the illness / condition-journal surface. + * v1.18.2 — the illness / condition-journal surface. * - * 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 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 + * A calm, retrospective journal. The episode history reads as a grid of + * medication-styled cards: a header (label link + neutral type / status + * badges + an overflow kebab) and a single bottom-pinned "Log a day" + * primary action — one clear action per card, the most-common one promoted. + * The list is split into "Active" and "Resolved" groups (active first), and + * flares nest under their parent condition so the parent relationship the + * form captures is visible. The cross-episode retrospective summary sits + * directly under the header so a long list never buries it. Each card + * navigates to the episode-detail surface (`/illness/[id]`) where the + * per-day timeline and correlation card live. Retrospective-only copy — a * journal, not a medical device, and it does not diagnose. */ -import { useState } from "react"; +import { useMemo, useState } from "react"; import Link from "next/link"; -import { ChevronRight, Plus, Stethoscope } from "lucide-react"; +import { Plus, Stethoscope } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { Card } from "@/components/ui/card"; +import { + Card, + CardAction, + CardContent, + CardHeader, + CardTitle, +} from "@/components/ui/card"; import { EmptyState } from "@/components/ui/empty-state"; import { Skeleton } from "@/components/ui/skeleton"; +import { cn } from "@/lib/utils"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { LogDaySheet } from "./log-day-sheet"; @@ -38,29 +49,55 @@ function todayLocal(): string { return `${y}-${m}-${d}`; } -function EpisodeRow({ - episode, - onLogDay, - onEdit, - onResolve, - resolving, -}: { +interface EpisodeCardProps { episode: IllnessEpisodeDTO; + /** Flares that hang off this episode (rendered nested), already filtered. */ + flares?: IllnessEpisodeDTO[]; onLogDay: (id: string) => void; onEdit: (episode: IllnessEpisodeDTO) => void; onResolve: (id: string) => void; resolving: boolean; -}) { +} + +function EpisodeCard({ + episode, + flares, + onLogDay, + onEdit, + onResolve, + resolving, +}: EpisodeCardProps) { const { t } = useTranslations(); const fmt = useFormatters(); const active = episode.resolvedAt === null; const isChronic = episode.lifecycle === "CHRONIC_ONGOING"; + const flareCount = flares?.length ?? 0; return ( - - -
- {episode.label} + + + + + {episode.label} + + + + onEdit(episode)} + onResolve={ + active && !isChronic ? () => onResolve(episode.id) : undefined + } + resolving={resolving} + /> + + + + +
{t(`illness.type.${episode.type}`)} {active @@ -70,41 +107,100 @@ function EpisodeRow({ : t("illness.status.recovered")}
-

- {t("illness.onsetOn", { date: fmt.dateShort(new Date(episode.onsetAt)) })} + +

+ {t("illness.onsetOn", { + date: fmt.dateShort(new Date(episode.onsetAt)), + })} {episode.resolvedAt ? ` · ${t("illness.recoveredOn", { date: fmt.dateShort(new Date(episode.resolvedAt)), })}` : ""}

- -
- - onEdit(episode)} - onResolve={ - active && !isChronic ? () => onResolve(episode.id) : undefined - } - resolving={resolving} - /> - -
+ + {flareCount > 0 ? ( +
+

+ {flareCount === 1 + ? t("illness.flares.countOne") + : t("illness.flares.countOther", { count: flareCount })} +

+
    + {flares!.map((flare) => ( +
  • + + {flare.label} + + {fmt.dateShort(new Date(flare.onsetAt))} + + +
  • + ))} +
+
+ ) : null} + + {/* Bottom-pinned single primary action — the daily-use "log a day" + flow, promoted from a busy inline cluster. The card navigates to + the detail surface on its title link; this is the one explicit + button. */} +
+ +
+
); } +function EpisodeGroup({ + title, + parents, + childrenByParent, + onLogDay, + onEdit, + onResolve, + resolving, +}: { + title: string; + parents: IllnessEpisodeDTO[]; + childrenByParent: Map; + onLogDay: (id: string) => void; + onEdit: (episode: IllnessEpisodeDTO) => void; + onResolve: (id: string) => void; + resolving: boolean; +}) { + if (parents.length === 0) return null; + return ( +
+

+ {title} +

+
+ {parents.map((episode) => ( + + ))} +
+
+ ); +} + export function IllnessView() { const { t } = useTranslations(); const { data: episodes, isLoading } = useIllnessEpisodes(true); @@ -117,6 +213,45 @@ export function IllnessView() { const logEpisode = episodes?.find((e) => e.id === logEpisodeId) ?? null; + /** + * Group the flat list into active / resolved, nesting flares under their + * parent condition. A flare nests only when its parent lives in the same + * status group (so an active flare under a resolved parent still surfaces as + * its own active card rather than vanishing); otherwise it renders as a + * standalone parent card in its own group. + */ + const grouped = useMemo(() => { + const list = episodes ?? []; + const byId = new Map(list.map((e) => [e.id, e])); + const isActive = (e: IllnessEpisodeDTO) => e.resolvedAt === null; + + const activeParents: IllnessEpisodeDTO[] = []; + const resolvedParents: IllnessEpisodeDTO[] = []; + const childrenByParent = new Map(); + + for (const e of list) { + const parent = e.parentConditionId + ? byId.get(e.parentConditionId) + : undefined; + const nestsUnderParent = + parent !== undefined && isActive(parent) === isActive(e); + + if (nestsUnderParent) { + const bucket = childrenByParent.get(parent.id) ?? []; + bucket.push(e); + childrenByParent.set(parent.id, bucket); + } else if (isActive(e)) { + activeParents.push(e); + } else { + resolvedParents.push(e); + } + } + + return { activeParents, resolvedParents, childrenByParent }; + }, [episodes]); + + const hasEpisodes = (episodes?.length ?? 0) > 0; + return (
@@ -140,26 +275,34 @@ export function IllnessView() {

{t("illness.disclaimer")}

{isLoading ? ( -
- - - +
+ +
- ) : episodes && episodes.length > 0 ? ( + ) : hasEpisodes ? ( <> -
- {episodes.map((episode) => ( - setLogEpisodeId(id)} - onEdit={(e) => setEditEpisode(e)} - onResolve={(id) => resolve.mutate(id)} - /> - ))} -
+ {/* Retrospective summary sits above the list so a long history + never buries it. */} + + setLogEpisodeId(id)} + onEdit={(e) => setEditEpisode(e)} + onResolve={(id) => resolve.mutate(id)} + /> + setLogEpisodeId(id)} + onEdit={(e) => setEditEpisode(e)} + onResolve={(id) => resolve.mutate(id)} + /> ) : (