mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
feat(illness): clarify layout — medication-style cards, active/resolved groups, nested flares, detail timeline
This commit is contained in:
commit
b47cbae539
9 files changed
+454
-64
No files matched your search
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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" />
|
||||
) : (
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user