feat(illness): add date-less day-log list and full-history timeline

The episode day-logs endpoint only accepted a single `date`, so the
detail timeline could anchor on today alone. Add a date-less LIST variant
to GET /api/illness/episodes/{id}/day-logs: when no `date` is given,
return the episode's day-logs newest-first with limit/offset/sortDir
paging and meta.total (mirrors the Labs list envelope). The single-date
read stays intact, selected by the param's presence.

Wire the detail timeline onto the list for a full historical scroll
instead of today only, keeping the neutral palette and retrospective
copy. Register the list response in the OpenAPI contract for the iOS
client.
This commit is contained in:
Marc-André Bombeck committed 2026-06-17 14:07:16 +02:00
1 parent b5c1d1fc4e
commit cdf43c3669
16 files changed
+482 -104

No files matched your search

+77 -66
View File
@@ -1,15 +1,16 @@
"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.
* v1.18.2 → v1.18.3 — the per-day timeline anchor, hosted exclusively on the
* episode detail surface. It surfaces the episode's logged days (symptoms with
* their 0–3 severity, functional impact, fever) newest-first as a full
* historical scroll, 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.
* The read uses the date-less `useIllnessDayLogList` hook (the episode's whole
* day-log history, paginated server-side); when nothing has been logged it
* renders a calm prompt to log a day.
*/
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
@@ -18,24 +19,81 @@ 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";
import { useIllnessDayLogList } from "./use-illness";
import type { IllnessDayLogDTO } from "./types";
function symptomLabelKey(key: string): string | null {
return ILLNESS_SYMPTOM_CATALOG.find((s) => s.key === key)?.labelKey ?? null;
}
/** One logged day rendered as a calm, retrospective block. */
function DayLogRow({ log }: { log: IllnessDayLogDTO }) {
const { t } = useTranslations();
const fmt = useFormatters();
return (
<div className="space-y-2 border-border/60 border-l-2 pl-3">
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
{fmt.dateShort(`${log.date}T12:00:00`)}
</p>
{log.symptoms.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{log.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 text-sm">
{t("illness.timeline.noSymptoms")}
</p>
)}
{log.functionalImpact !== null ? (
<p className="text-foreground text-sm">
{t("illness.timeline.impact", {
impact: t(`illness.impact.${log.functionalImpact}`),
})}
</p>
) : null}
{log.feverC !== null ? (
<p className="text-foreground text-sm">
{t("illness.timeline.fever", { value: fmt.number(log.feverC, 1) })}
</p>
) : null}
{log.note ? (
<p className="text-muted-foreground text-sm whitespace-pre-wrap">
{log.note}
</p>
) : null}
</div>
);
}
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);
const { data, isLoading, isError } = useIllnessDayLogList(episodeId, "desc");
const dayLogs = data?.dayLogs ?? [];
return (
<Card>
@@ -54,10 +112,10 @@ export function IllnessDayTimeline({
<p className="text-muted-foreground text-sm">
{t("illness.timeline.error")}
</p>
) : !data ? (
) : dayLogs.length === 0 ? (
<div className="space-y-3">
<p className="text-muted-foreground text-sm">
{t("illness.timeline.emptyToday")}
{t("illness.timeline.empty")}
</p>
<Button
variant="outline"
@@ -68,57 +126,10 @@ export function IllnessDayTimeline({
</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 className="space-y-4">
{dayLogs.map((log) => (
<DayLogRow key={log.id} log={log} />
))}
</div>
)}
</CardContent>
@@ -121,7 +121,6 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) {
{episode ? (
<IllnessDayTimeline
episodeId={episode.id}
date={today}
onLogDay={() => setLogOpen(true)}
/>
) : null}
+10
View File
@@ -47,6 +47,16 @@ export interface IllnessDayLogDTO {
updatedAt: string;
}
/**
* v1.18.3 — the date-less day-log LIST response (`GET .../day-logs` with no
* `date`). Server-authoritative; iOS (healthlog-iOS#30) renders the rows +
* pages on `meta.total`. Mirrors the Labs list envelope shape.
*/
export interface IllnessDayLogListResponse {
dayLogs: IllnessDayLogDTO[];
meta: { total: number; limit: number; offset: number };
}
export interface IllnessEpisodeCreateInput {
label: string;
type: IllnessType;
+20
View File
@@ -17,6 +17,7 @@ import type {
IllnessCorrelationResponse,
IllnessDayLogDTO,
IllnessDayLogInput,
IllnessDayLogListResponse,
IllnessEpisodeCreateInput,
IllnessEpisodeDTO,
IllnessEpisodeUpdateInput,
@@ -83,6 +84,25 @@ export function useIllnessDayLog(episodeId: string | null, date: string) {
});
}
/**
* v1.18.3 — the episode's full day-log history, newest-first (date-less list).
* Powers the detail timeline's historical scroll instead of anchoring on
* today. Server-authoritative; the response carries `meta.total` for paging.
*/
export function useIllnessDayLogList(
episodeId: string | null,
sortDir: "asc" | "desc" = "desc",
) {
return useQuery({
queryKey: queryKeys.illnessDayLogList(episodeId ?? "none", sortDir),
enabled: episodeId !== null,
queryFn: () =>
apiGet<IllnessDayLogListResponse>(
`/api/illness/episodes/${episodeId}/day-logs?sortDir=${sortDir}`,
),
});
}
function useInvalidateIllness() {
const qc = useQueryClient();
return () => qc.invalidateQueries({ queryKey: queryKeys.illness() });