feat(illness): clarify the condition-journal layout

Rework the illness list so each episode reads as a medication-styled card:
the header carries the label link, neutral type / status badges, and the
overflow kebab; a single bottom-pinned "Log a day" button is the one
explicit action per card. Drop the redundant chevron — the card title is
already the link.

Split the flat list into Active / Resolved groups (active first) and nest
flares under their parent condition so the parent relationship the form
captures becomes visible. Lift the retrospective summary above the list so a
long history no longer buries it.

Host the per-day timeline exclusively on the detail surface: the list rows
stay clean summaries, and navigating reveals today's logged symptoms,
functional impact, fever, and note alongside the correlation card. Neutral
palette and retrospective-only copy throughout.
This commit is contained in:
Marc-André Bombeck committed 2026-06-17 07:45:36 +02:00
1 parent cc4a21b97e
commit 89b6516803
9 files changed
+454 -64

No files matched your search

+18
View File
@@ -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": {
+18
View File
@@ -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": {
+18
View File
@@ -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": {
+18
View File
@@ -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": {
+18
View File
@@ -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": {
+18
View File
@@ -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": {
@@ -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 (
<Card>
<CardHeader>
<h2 className="text-base font-semibold">
{t("illness.timeline.title")}
</h2>
</CardHeader>
<CardContent className="space-y-3">
{isLoading ? (
<div className="space-y-2">
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-4 w-2/3" />
</div>
) : isError ? (
<p className="text-muted-foreground text-sm">
{t("illness.timeline.error")}
</p>
) : !data ? (
<div className="space-y-3">
<p className="text-muted-foreground text-sm">
{t("illness.timeline.emptyToday")}
</p>
<Button
variant="outline"
className="min-h-11 sm:min-h-9"
onClick={onLogDay}
>
{t("illness.logDay")}
</Button>
</div>
) : (
<div className="space-y-2.5 text-sm">
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
{t("illness.timeline.today", {
date: fmt.dateShort(`${data.date}T12:00:00`),
})}
</p>
{data.symptoms.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{data.symptoms.map((s) => {
const labelKey = symptomLabelKey(s.key);
if (!labelKey) return null;
return (
<Badge key={s.key} variant="secondary">
{t(labelKey)}
{typeof s.severity === "number"
? ` · ${t("illness.timeline.severity", {
severity: s.severity,
})}`
: ""}
</Badge>
);
})}
</div>
) : (
<p className="text-muted-foreground">
{t("illness.timeline.noSymptoms")}
</p>
)}
{data.functionalImpact !== null ? (
<p className="text-foreground">
{t("illness.timeline.impact", {
impact: t(`illness.impact.${data.functionalImpact}`),
})}
</p>
) : null}
{data.feverC !== null ? (
<p className="text-foreground">
{t("illness.timeline.fever", {
value: fmt.number(data.feverC, 1),
})}
</p>
) : null}
{data.note ? (
<p className="text-muted-foreground whitespace-pre-wrap">
{data.note}
</p>
) : null}
</div>
)}
</CardContent>
</Card>
);
}
@@ -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 }) {
</Card>
) : 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 ? (
<IllnessDayTimeline
episodeId={episode.id}
date={today}
onLogDay={() => setLogOpen(true)}
/>
) : null}
{isLoading ? (
<Skeleton className="h-40 w-full" />
) : (
+207 -64
View File
@@ -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 (
<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>
<Card className="h-full gap-3 md:gap-3">
<CardHeader className="pb-0">
<CardTitle className="min-w-0">
<Link
href={`/illness/${episode.id}`}
className="hover:underline focus-visible:underline"
>
<span className="block truncate">{episode.label}</span>
</Link>
</CardTitle>
<CardAction>
<EpisodeMenu
episode={episode}
onEdit={() => onEdit(episode)}
onResolve={
active && !isChronic ? () => onResolve(episode.id) : undefined
}
resolving={resolving}
/>
</CardAction>
</CardHeader>
<CardContent className="flex h-full flex-col space-y-3">
<div className="flex flex-wrap items-center gap-1.5">
<Badge variant="secondary">{t(`illness.type.${episode.type}`)}</Badge>
<Badge variant="outline">
{active
@@ -70,41 +107,100 @@ function EpisodeRow({
: t("illness.status.recovered")}
</Badge>
</div>
<p className="text-muted-foreground mt-1 text-xs">
{t("illness.onsetOn", { date: fmt.dateShort(new Date(episode.onsetAt)) })}
<p 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)),
})}`
: ""}
</p>
</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>
<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>
{flareCount > 0 ? (
<div className="space-y-1 border-l-2 pl-3">
<p className="text-muted-foreground text-xs font-medium">
{flareCount === 1
? t("illness.flares.countOne")
: t("illness.flares.countOther", { count: flareCount })}
</p>
<ul className="space-y-1">
{flares!.map((flare) => (
<li key={flare.id}>
<Link
href={`/illness/${flare.id}`}
className="text-foreground/80 hover:text-foreground flex items-center gap-2 text-xs hover:underline"
>
<span className="truncate">{flare.label}</span>
<span className="text-muted-foreground shrink-0">
{fmt.dateShort(new Date(flare.onsetAt))}
</span>
</Link>
</li>
))}
</ul>
</div>
) : 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. */}
<div className="mt-auto pt-0">
<Button
className="min-h-11 w-full sm:min-h-9"
onClick={() => onLogDay(episode.id)}
>
{t("illness.logDay")}
</Button>
</div>
</CardContent>
</Card>
);
}
function EpisodeGroup({
title,
parents,
childrenByParent,
onLogDay,
onEdit,
onResolve,
resolving,
}: {
title: string;
parents: IllnessEpisodeDTO[];
childrenByParent: Map<string, IllnessEpisodeDTO[]>;
onLogDay: (id: string) => void;
onEdit: (episode: IllnessEpisodeDTO) => void;
onResolve: (id: string) => void;
resolving: boolean;
}) {
if (parents.length === 0) return null;
return (
<section className="space-y-3">
<h2 className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
{title}
</h2>
<div className="grid gap-4 sm:grid-cols-2">
{parents.map((episode) => (
<EpisodeCard
key={episode.id}
episode={episode}
flares={childrenByParent.get(episode.id)}
resolving={resolving}
onLogDay={onLogDay}
onEdit={onEdit}
onResolve={onResolve}
/>
))}
</div>
</section>
);
}
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<string, IllnessEpisodeDTO[]>();
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 (
<div className="space-y-6">
<div className="flex items-start justify-between gap-3">
@@ -140,26 +275,34 @@ export function IllnessView() {
<p className="text-muted-foreground text-xs">{t("illness.disclaimer")}</p>
{isLoading ? (
<div className="space-y-3">
<Skeleton className="h-20 w-full" />
<Skeleton className="h-20 w-full" />
<Skeleton className="h-20 w-full" />
<div className={cn("grid gap-4 sm:grid-cols-2")}>
<Skeleton className="h-32 w-full" />
<Skeleton className="h-32 w-full" />
</div>
) : episodes && episodes.length > 0 ? (
) : hasEpisodes ? (
<>
<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>
{/* Retrospective summary sits above the list so a long history
never buries it. */}
<IllnessInsightsCard />
<EpisodeGroup
title={t("illness.section.active")}
parents={grouped.activeParents}
childrenByParent={grouped.childrenByParent}
resolving={resolve.isPending}
onLogDay={(id) => setLogEpisodeId(id)}
onEdit={(e) => setEditEpisode(e)}
onResolve={(id) => resolve.mutate(id)}
/>
<EpisodeGroup
title={t("illness.section.resolved")}
parents={grouped.resolvedParents}
childrenByParent={grouped.childrenByParent}
resolving={resolve.isPending}
onLogDay={(id) => setLogEpisodeId(id)}
onEdit={(e) => setEditEpisode(e)}
onResolve={(id) => resolve.mutate(id)}
/>
</>
) : (
<EmptyState