merge: Labs overhaul — biomarker catalog, structured entry, dashboard-style chart, edit/undo, backfill

# Conflicts:
#	src/app/api/labs/route.ts
This commit is contained in:
Marc-André Bombeck committed 2026-06-16 21:17:09 +02:00
commit cc32e2adf7
40 files changed
+4403 -360

No files matched your search

+281
View File
@@ -0,0 +1,281 @@
"use client";
import { useState } from "react";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { ApiError, apiPost, apiPut } from "@/lib/api/api-fetch";
import {
BIOMARKER_CATALOG,
BIOMARKER_PANELS,
} from "@/lib/labs/biomarker-catalog";
import { useTranslations } from "@/lib/i18n/context";
import type { BiomarkerDto } from "./types";
const CONTEXT_MAX_LENGTH = 2000;
/** Parse a free-text decimal that may use a comma separator. */
function parseDecimal(raw: string): number | null {
const trimmed = raw.trim().replace(",", ".");
if (trimmed === "") return null;
const n = Number(trimmed);
return Number.isFinite(n) ? n : null;
}
interface BiomarkerFormProps {
/** When set the form edits this marker; otherwise it defines a new one. */
existing?: BiomarkerDto;
onSuccess?: (saved: BiomarkerDto) => void;
onCancel?: () => void;
}
/**
* v1.18.1 — define or edit a Biomarker catalog entry.
*
* A marker is the Labs feature's reusable definition: name, unit, reference
* bounds (set ONCE, never re-entered per reading), and an optional context
* note. When defining a new marker the user can seed the fields from the
* common-panel catalog (a `<Select>` grouped by panel) or fill them by hand.
* Either bound is optional (a marker may report only an upper bound, e.g.
* LDL < 116).
*/
export function BiomarkerForm({
existing,
onSuccess,
onCancel,
}: BiomarkerFormProps) {
const { t } = useTranslations();
const [name, setName] = useState(existing?.name ?? "");
const [unit, setUnit] = useState(existing?.unit ?? "");
const [lower, setLower] = useState(
existing?.lowerBound != null ? String(existing.lowerBound) : "",
);
const [upper, setUpper] = useState(
existing?.upperBound != null ? String(existing.upperBound) : "",
);
const [panel, setPanel] = useState(existing?.panel ?? "");
const [context, setContext] = useState(existing?.context ?? "");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
function applySeed(slug: string) {
const seed = BIOMARKER_CATALOG.find((s) => s.slug === slug);
if (!seed) return;
setName(t(`labs.catalog.${seed.slug}`));
setUnit(seed.unit);
setLower(seed.lowerBound != null ? String(seed.lowerBound) : "");
setUpper(seed.upperBound != null ? String(seed.upperBound) : "");
setPanel(t(`labs.catalog.panel.${seed.panel}`));
setError(null);
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
if (!name.trim() || !unit.trim()) {
setError(t("labs.biomarker.form.requiredError"));
return;
}
const low = parseDecimal(lower);
const high = parseDecimal(upper);
if (low !== null && high !== null && low > high) {
setError(t("labs.biomarker.form.rangeOrderError"));
return;
}
const body = {
name: name.trim(),
unit: unit.trim(),
...(low !== null ? { lowerBound: low } : {}),
...(high !== null ? { upperBound: high } : {}),
...(panel.trim() ? { panel: panel.trim() } : {}),
...(context.trim() ? { context: context.trim() } : {}),
};
setSubmitting(true);
try {
const saved = existing
? await apiPut<BiomarkerDto>(`/api/biomarkers/${existing.id}`, body)
: await apiPost<BiomarkerDto>("/api/biomarkers", body);
toast.success(
existing
? t("labs.biomarker.form.updatedToast")
: t("labs.biomarker.form.savedToast"),
);
onSuccess?.(saved);
} catch (err) {
const message =
err instanceof ApiError ? err.message : t("labs.biomarker.form.saveError");
setError(message);
} finally {
setSubmitting(false);
}
}
return (
<form onSubmit={handleSubmit} className="space-y-5">
{!existing ? (
<div className="space-y-1.5">
<Label htmlFor="biomarker-seed">
{t("labs.biomarker.form.seedLabel")}
</Label>
<Select onValueChange={applySeed}>
<SelectTrigger id="biomarker-seed" className="w-full">
<SelectValue
placeholder={t("labs.biomarker.form.seedPlaceholder")}
/>
</SelectTrigger>
<SelectContent>
{BIOMARKER_PANELS.map((panelKey) => {
const items = BIOMARKER_CATALOG.filter(
(s) => s.panel === panelKey,
);
if (items.length === 0) return null;
return (
<SelectGroup key={panelKey}>
<SelectLabel>
{t(`labs.catalog.panel.${panelKey}`)}
</SelectLabel>
{items.map((s) => (
<SelectItem key={s.slug} value={s.slug}>
{t(`labs.catalog.${s.slug}`)}
</SelectItem>
))}
</SelectGroup>
);
})}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
{t("labs.biomarker.form.seedHint")}
</p>
</div>
) : null}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5 sm:col-span-2">
<Label htmlFor="biomarker-name">
{t("labs.biomarker.form.name")}
</Label>
<Input
id="biomarker-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("labs.biomarker.form.namePlaceholder")}
maxLength={120}
autoFocus
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="biomarker-unit">
{t("labs.biomarker.form.unit")}
</Label>
<Input
id="biomarker-unit"
value={unit}
onChange={(e) => setUnit(e.target.value)}
placeholder={t("labs.biomarker.form.unitPlaceholder")}
maxLength={40}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="biomarker-panel">
{t("labs.biomarker.form.panel")}
</Label>
<Input
id="biomarker-panel"
value={panel}
onChange={(e) => setPanel(e.target.value)}
placeholder={t("labs.biomarker.form.panelPlaceholder")}
maxLength={120}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="biomarker-lower">
{t("labs.biomarker.form.lowerBound")}
</Label>
<Input
id="biomarker-lower"
inputMode="decimal"
value={lower}
onChange={(e) => setLower(e.target.value)}
placeholder={t("labs.biomarker.form.lowerPlaceholder")}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="biomarker-upper">
{t("labs.biomarker.form.upperBound")}
</Label>
<Input
id="biomarker-upper"
inputMode="decimal"
value={upper}
onChange={(e) => setUpper(e.target.value)}
placeholder={t("labs.biomarker.form.upperPlaceholder")}
/>
</div>
<div className="space-y-1.5 sm:col-span-2">
<Label htmlFor="biomarker-context">
{t("labs.biomarker.form.context")}
</Label>
<Textarea
id="biomarker-context"
value={context}
onChange={(e) => setContext(e.target.value)}
placeholder={t("labs.biomarker.form.contextPlaceholder")}
maxLength={CONTEXT_MAX_LENGTH}
rows={2}
/>
</div>
</div>
<p className="text-muted-foreground text-xs">
{t("labs.biomarker.form.rangeHint")}
</p>
{error ? (
<p className="text-destructive text-sm" role="alert">
{error}
</p>
) : null}
<div className="flex justify-end gap-2">
{onCancel ? (
<Button
type="button"
variant="ghost"
onClick={onCancel}
disabled={submitting}
>
{t("common.cancel")}
</Button>
) : null}
<Button type="submit" disabled={submitting}>
{submitting ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("labs.biomarker.form.save")}
</Button>
</div>
</form>
);
}
+177
View File
@@ -0,0 +1,177 @@
"use client";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { FlaskConical, Pencil, Plus } from "lucide-react";
import { toast } from "sonner";
import { DeleteButton } from "@/components/data-list";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { apiDelete, apiGet } from "@/lib/api/api-fetch";
import { formatReferenceRange } from "@/lib/labs/reference-range";
import { formatLabValue } from "@/lib/labs/format-value";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { BiomarkerForm } from "./biomarker-form";
import type { BiomarkerDto, BiomarkerListResponse } from "./types";
/**
* v1.18.1 — the Biomarker catalog manager.
*
* Lives on the Labs page (not Settings) because the catalog IS the Labs
* feature's primary object. Lists every defined marker with its unit +
* reference range, opens a define-new / edit sheet, and wires delete (the
* `onDelete: SetNull` FK keeps existing readings, just unlinks them).
*/
export function BiomarkerManager() {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [formOpen, setFormOpen] = useState(false);
const [editing, setEditing] = useState<BiomarkerDto | null>(null);
const { data, isLoading, isError } = useQuery({
queryKey: queryKeys.biomarkers(),
queryFn: () => apiGet<BiomarkerListResponse>("/api/biomarkers"),
});
const deleteMutation = useMutation({
mutationFn: (id: string) => apiDelete(`/api/biomarkers/${id}`),
onSuccess: () => {
toast.success(t("labs.biomarker.deletedToast"));
queryClient.invalidateQueries({ queryKey: queryKeys.biomarkers() });
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
},
onError: () => toast.error(t("labs.biomarker.deleteError")),
});
function openNew() {
setEditing(null);
setFormOpen(true);
}
function openEdit(marker: BiomarkerDto) {
setEditing(marker);
setFormOpen(true);
}
function afterSave() {
setFormOpen(false);
setEditing(null);
queryClient.invalidateQueries({ queryKey: queryKeys.biomarkers() });
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
}
const markers = data?.biomarkers ?? [];
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-3">
<p className="text-muted-foreground text-sm">
{t("labs.biomarker.managerDescription")}
</p>
<Button size="sm" onClick={openNew} className="shrink-0">
<Plus className="h-4 w-4" />
{t("labs.biomarker.define")}
</Button>
</div>
{isLoading ? (
<div className="space-y-2">
{Array.from({ length: 3 }, (_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
) : isError ? (
<p className="text-destructive py-6 text-center text-sm">
{t("labs.biomarker.loadError")}
</p>
) : markers.length === 0 ? (
<EmptyState
icon={<FlaskConical className="size-6" />}
title={t("labs.biomarker.emptyTitle")}
description={t("labs.biomarker.emptyDescription")}
action={
<Button onClick={openNew}>{t("labs.biomarker.defineFirst")}</Button>
}
/>
) : (
<ul className="space-y-2">
{markers.map((marker) => {
const range = formatReferenceRange(
marker.lowerBound,
marker.upperBound,
formatLabValue,
);
return (
<li key={marker.id}>
<Card>
<CardContent className="flex items-center justify-between gap-3 py-3">
<div className="min-w-0">
<div className="flex flex-wrap items-baseline gap-x-2">
<span className="truncate text-sm font-medium">
{marker.name}
</span>
<span className="text-muted-foreground text-xs">
{marker.unit}
</span>
</div>
<p className="text-muted-foreground text-xs">
{range
? `${t("labs.referenceLabel")} ${range} ${marker.unit}`
: t("labs.biomarker.noRange")}
{marker.panel ? ` · ${marker.panel}` : ""}
</p>
</div>
<div className="flex items-center gap-1">
<Button
size="icon"
variant="ghost"
className="size-9"
onClick={() => openEdit(marker)}
aria-label={t("labs.biomarker.edit")}
>
<Pencil className="h-4 w-4" />
</Button>
<DeleteButton
onConfirm={() => deleteMutation.mutate(marker.id)}
title={t("labs.biomarker.deleteConfirmTitle")}
description={t(
"labs.biomarker.deleteConfirmDescription",
)}
confirmLabel={t("labs.biomarker.delete")}
className="size-9"
iconClassName="h-4 w-4"
/>
</div>
</CardContent>
</Card>
</li>
);
})}
</ul>
)}
<ResponsiveSheet
open={formOpen}
onOpenChange={setFormOpen}
title={
editing
? t("labs.biomarker.editTitle")
: t("labs.biomarker.defineTitle")
}
description={t("labs.biomarker.defineDescription")}
>
<BiomarkerForm
existing={editing ?? undefined}
onSuccess={afterSave}
onCancel={() => setFormOpen(false)}
/>
</ResponsiveSheet>
</div>
);
}
+224
View File
@@ -0,0 +1,224 @@
"use client";
import { useMemo, useState } from "react";
import {
CartesianGrid,
ComposedChart,
Line,
ReferenceArea,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { Button } from "@/components/ui/button";
import { CHART_HEIGHT_PX } from "@/lib/charts/constants";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
import { formatDateShort } from "@/lib/format";
import { formatLabValue } from "@/lib/labs/format-value";
import { useTranslations } from "@/lib/i18n/context";
import { RichChartTooltip, type RichTooltipRow } from "../charts/chart-tooltip";
import type { LabResultDto } from "./types";
/**
* v1.18.1 — per-biomarker trend chart, in the dashboard's visual language.
*
* Replaces the hand-rolled 72×20 SVG sparkline with a proper Recharts
* `ComposedChart`: a single line over time, the catalog reference window
* painted as a shaded `ReferenceArea` band with dashed bound lines, range
* tabs (30 / 90 / 365 / all), and the shared rich tooltip. The band uses a
* MUTED primary fill — never a danger colour — honouring the no-alarming-
* colour rule. Every reading carries the same y-axis unit because a chart is
* one biomarker only.
*/
const RANGE_DAYS = [
{ key: "30", days: 30 },
{ key: "90", days: 90 },
{ key: "365", days: 365 },
{ key: "all", days: 0 },
] as const;
type RangeKey = (typeof RANGE_DAYS)[number]["key"];
interface ChartPoint {
timestamp: number;
value: number;
}
export function LabBiomarkerChart({
readings,
unit,
lowerBound,
upperBound,
}: {
/** Readings for this biomarker, any order (sorted internally). */
readings: LabResultDto[];
unit: string;
lowerBound: number | null;
upperBound: number | null;
}) {
const { t } = useTranslations();
const [range, setRange] = useState<RangeKey>("365");
// Capture "now" once at mount so the recency filter stays pure across
// re-renders (an inline `Date.now()` in render is impure).
const [nowMs] = useState(() => Date.now());
const points = useMemo<ChartPoint[]>(() => {
const sorted = [...readings].sort(
(a, b) =>
new Date(a.takenAt).getTime() - new Date(b.takenAt).getTime(),
);
const days = RANGE_DAYS.find((r) => r.key === range)?.days ?? 0;
const filtered =
days > 0
? sorted.filter((r) => {
const ageMs = nowMs - new Date(r.takenAt).getTime();
return ageMs <= days * 24 * 60 * 60 * 1000;
})
: sorted;
return filtered.map((r) => ({
timestamp: new Date(r.takenAt).getTime(),
value: r.value,
}));
}, [readings, range, nowMs]);
// Y domain wraps both the data and the reference window so the band is
// always visible even when every reading sits inside it.
const yDomain = useMemo<[number, number]>(() => {
const values = points.map((p) => p.value);
const candidates = [...values];
if (lowerBound != null) candidates.push(lowerBound);
if (upperBound != null) candidates.push(upperBound);
if (candidates.length === 0) return [0, 1];
const min = Math.min(...candidates);
const max = Math.max(...candidates);
const pad = (max - min || Math.abs(max) || 1) * 0.1;
return [min - pad, max + pad];
}, [points, lowerBound, upperBound]);
const animate = !prefersReducedMotion();
const primary = "var(--primary)";
return (
<div className="space-y-3">
<div className="flex justify-end gap-1">
{RANGE_DAYS.map((r) => (
<Button
key={r.key}
size="sm"
variant={range === r.key ? "secondary" : "ghost"}
className="h-7 px-2 text-xs"
onClick={() => setRange(r.key)}
>
{t(`labs.chart.range.${r.key}`)}
</Button>
))}
</div>
{points.length === 0 ? (
<p className="text-muted-foreground py-10 text-center text-sm">
{t("labs.chart.empty")}
</p>
) : (
<div style={{ height: CHART_HEIGHT_PX }}>
<ResponsiveContainer width="100%" height="100%">
<ComposedChart
data={points}
margin={{ top: 8, right: 12, bottom: 4, left: 0 }}
>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--border)"
vertical={false}
/>
<XAxis
dataKey="timestamp"
type="number"
scale="time"
domain={["dataMin", "dataMax"]}
tickFormatter={(ts: number) => formatDateShort(new Date(ts))}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
stroke="var(--border)"
minTickGap={32}
/>
<YAxis
domain={yDomain}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
stroke="var(--border)"
width={44}
tickFormatter={(v: number) => formatLabValue(v)}
/>
{/* Reference window — muted band + dashed bounds. No alarm
colour: the in/out verdict is shown by the neutral badge,
not by painting the chart red. */}
{lowerBound != null && upperBound != null ? (
<ReferenceArea
y1={lowerBound}
y2={upperBound}
fill={primary}
fillOpacity={0.1}
stroke="none"
/>
) : null}
{lowerBound != null ? (
<ReferenceLine
y={lowerBound}
stroke={primary}
strokeOpacity={0.4}
strokeDasharray="4 4"
/>
) : null}
{upperBound != null ? (
<ReferenceLine
y={upperBound}
stroke={primary}
strokeOpacity={0.4}
strokeDasharray="4 4"
/>
) : null}
<Tooltip
content={(props) => {
const active = props.active ?? false;
const payload = props.payload as
| ReadonlyArray<{ payload?: ChartPoint }>
| undefined;
const point = payload?.[0]?.payload;
if (!active || !point) {
return <RichChartTooltip active={false} rows={[]} />;
}
const rows: RichTooltipRow[] = [
{
name: t("labs.chart.valueLabel"),
value: `${formatLabValue(point.value)} ${unit}`,
color: primary,
},
];
return (
<RichChartTooltip
active
label={formatDateShort(new Date(point.timestamp))}
rows={rows}
/>
);
}}
/>
<Line
type="monotone"
dataKey="value"
stroke={primary}
strokeWidth={2}
dot={{ r: 3, fill: primary }}
activeDot={{ r: 5 }}
isAnimationActive={animate}
/>
</ComposedChart>
</ResponsiveContainer>
</div>
)}
</div>
);
}
@@ -0,0 +1,181 @@
"use client";
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { FlaskConical, Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { apiGet } from "@/lib/api/api-fetch";
import { classifyReferenceRange } from "@/lib/labs/reference-range";
import { formatLabValue } from "@/lib/labs/format-value";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { LabBiomarkerChart } from "./lab-biomarker-chart";
import { LabForm } from "./lab-form";
import { LabHistoryList } from "./lab-history-list";
import { ReferenceRangeBadge } from "./reference-range-badge";
import type {
BiomarkerDto,
LabResultDto,
LabResultListResponse,
} from "./types";
/**
* v1.18.1 — per-biomarker detail: heading + current-value badge, the proper
* dashboard-style chart with the reference band, and the editable reading
* history. "Add value" pre-selects this biomarker so the user never re-picks
* it. Mirrors the measurement-detail layout.
*/
export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [addOpen, setAddOpen] = useState(false);
const { data: marker, isError: markerError } = useQuery({
queryKey: queryKeys.biomarkerDetail(biomarkerId),
queryFn: () => apiGet<BiomarkerDto>(`/api/biomarkers/${biomarkerId}`),
});
const {
data: list,
isLoading,
isError: listError,
} = useQuery({
queryKey: queryKeys.labResultsList({
biomarkerId,
analyte: undefined,
panel: undefined,
from: undefined,
to: undefined,
page: 0,
sortDir: "desc",
}),
queryFn: () =>
apiGet<LabResultListResponse>(
`/api/labs?biomarkerId=${encodeURIComponent(biomarkerId)}&limit=500&sortDir=desc`,
),
});
const readings: LabResultDto[] = list?.results ?? [];
const latest =
readings.length > 0
? [...readings].sort(
(a, b) =>
new Date(b.takenAt).getTime() - new Date(a.takenAt).getTime(),
)[0]
: null;
function afterAdd() {
setAddOpen(false);
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
}
if (markerError || listError) {
return (
<p className="text-destructive py-8 text-center text-sm">
{t("labs.loadError")}
</p>
);
}
const latestStatus = latest
? classifyReferenceRange(
latest.value,
marker?.lowerBound ?? null,
marker?.upperBound ?? null,
)
: "unknown";
return (
<div className="space-y-6">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h1 className="text-2xl font-bold tracking-tight">
{marker?.name ?? <Skeleton className="h-7 w-32" />}
</h1>
{marker?.unit ? (
<p className="text-muted-foreground text-xs sm:text-sm">
{marker.unit}
{marker.panel ? ` · ${marker.panel}` : ""}
</p>
) : null}
</div>
<Button onClick={() => setAddOpen(true)} className="shrink-0">
<Plus className="h-4 w-4" />
{t("labs.addResult")}
</Button>
</div>
{latest ? (
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<span className="text-2xl font-bold tabular-nums">
{formatLabValue(latest.value)}{" "}
<span className="text-muted-foreground text-base font-normal">
{marker?.unit ?? latest.unit}
</span>
</span>
<ReferenceRangeBadge status={latestStatus} />
</div>
) : null}
{marker?.context ? (
<p className="text-muted-foreground text-sm">{marker.context}</p>
) : null}
<Card>
<CardContent className="pt-6">
{isLoading ? (
<Skeleton className="h-60 w-full" />
) : readings.length === 0 ? (
<EmptyState
icon={<FlaskConical className="size-6" />}
title={t("labs.detail.emptyTitle")}
description={t("labs.detail.emptyDescription")}
action={
<Button onClick={() => setAddOpen(true)}>
{t("labs.addResult")}
</Button>
}
/>
) : (
<LabBiomarkerChart
readings={readings}
unit={marker?.unit ?? latest?.unit ?? ""}
lowerBound={marker?.lowerBound ?? null}
upperBound={marker?.upperBound ?? null}
/>
)}
</CardContent>
</Card>
{readings.length > 0 ? (
<div className="space-y-2">
<h2 className="text-sm font-semibold">{t("labs.detail.history")}</h2>
<Card>
<CardContent className="py-0">
<LabHistoryList readings={readings} />
</CardContent>
</Card>
</div>
) : null}
<ResponsiveSheet
open={addOpen}
onOpenChange={setAddOpen}
title={t("labs.addResult")}
description={t("labs.addDescription")}
>
<LabForm
lockedBiomarkerId={biomarkerId}
onSuccess={afterAdd}
onCancel={() => setAddOpen(false)}
/>
</ResponsiveSheet>
</div>
);
}
+171 -124
View File
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
@@ -8,13 +9,30 @@ import { Button } from "@/components/ui/button";
import { DateTimeInput } from "@/components/ui/date-input";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { ApiError, apiPost } from "@/lib/api/api-fetch";
import { ApiError, apiGet, apiPost } from "@/lib/api/api-fetch";
import { formatReferenceRange } from "@/lib/labs/reference-range";
import { formatLabValue } from "@/lib/labs/format-value";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { LabResultDto } from "./types";
import { BiomarkerForm } from "./biomarker-form";
import type {
BiomarkerDto,
BiomarkerListResponse,
LabResultDto,
} from "./types";
const NOTE_MAX_LENGTH = 2000;
const DEFINE_NEW = "__define_new__";
function defaultTakenAtValue() {
const now = new Date();
@@ -32,59 +50,83 @@ function parseDecimal(raw: string): number | null {
}
interface LabFormProps {
/** When set, the biomarker is pre-selected and locked (add-from-detail). */
lockedBiomarkerId?: string;
onSuccess?: (created: LabResultDto) => void;
onCancel?: () => void;
}
/**
* v1.17.1 — manual lab-result entry.
* v1.18.1 — structured lab-result entry.
*
* Progressive disclosure: the four essentials (analyte, value, unit, date)
* are always visible; the optional reference range, panel grouping, and
* note sit below under a clearly-labelled "optional" heading so a quick
* single-value entry stays a four-field affair.
* The error-prone free-text path is gone: the user PICKS a biomarker from the
* catalog, then enters only the value against its known unit + reference range
* (resolved server-side). A "+ define new" row opens the marker-definition
* sheet inline and returns with it selected. Panel / unit / range are NOT
* re-entered per reading — they live on the biomarker. Only the per-reading
* note stays optional here.
*/
export function LabForm({ onSuccess, onCancel }: LabFormProps) {
export function LabForm({
lockedBiomarkerId,
onSuccess,
onCancel,
}: LabFormProps) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [analyte, setAnalyte] = useState("");
const { data: catalog, isLoading: catalogLoading } = useQuery({
queryKey: queryKeys.biomarkers(),
queryFn: () => apiGet<BiomarkerListResponse>("/api/biomarkers"),
});
const [biomarkerId, setBiomarkerId] = useState<string>(
lockedBiomarkerId ?? "",
);
const [value, setValue] = useState("");
const [unit, setUnit] = useState("");
const [takenAt, setTakenAt] = useState(defaultTakenAtValue);
const [panel, setPanel] = useState("");
const [refLow, setRefLow] = useState("");
const [refHigh, setRefHigh] = useState("");
const [note, setNote] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [defineOpen, setDefineOpen] = useState(false);
const markers = catalog?.biomarkers ?? [];
const selected = markers.find((m) => m.id === biomarkerId);
function handleSelect(next: string) {
if (next === DEFINE_NEW) {
setDefineOpen(true);
return;
}
setBiomarkerId(next);
setError(null);
}
function afterDefine(created: BiomarkerDto) {
setDefineOpen(false);
queryClient.invalidateQueries({ queryKey: queryKeys.biomarkers() });
setBiomarkerId(created.id);
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
const numericValue = parseDecimal(value);
if (!analyte.trim() || numericValue === null || !unit.trim()) {
setError(t("labs.form.requiredError"));
if (!biomarkerId) {
setError(t("labs.form.pickBiomarkerError"));
return;
}
const low = parseDecimal(refLow);
const high = parseDecimal(refHigh);
if (low !== null && high !== null && low > high) {
setError(t("labs.form.rangeOrderError"));
if (numericValue === null) {
setError(t("labs.form.requiredError"));
return;
}
setSubmitting(true);
try {
const created = await apiPost<LabResultDto>("/api/labs", {
analyte: analyte.trim(),
biomarkerId,
value: numericValue,
unit: unit.trim(),
takenAt: new Date(takenAt).toISOString(),
...(panel.trim() ? { panel: panel.trim() } : {}),
...(low !== null ? { referenceLow: low } : {}),
...(high !== null ? { referenceHigh: high } : {}),
...(note.trim() ? { note: note.trim() } : {}),
});
toast.success(t("labs.form.savedToast"));
@@ -98,94 +140,87 @@ export function LabForm({ onSuccess, onCancel }: LabFormProps) {
}
}
return (
<form onSubmit={handleSubmit} className="space-y-5">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5 sm:col-span-2">
<Label htmlFor="lab-analyte">{t("labs.form.analyte")}</Label>
<Input
id="lab-analyte"
value={analyte}
onChange={(e) => setAnalyte(e.target.value)}
placeholder={t("labs.form.analytePlaceholder")}
maxLength={120}
autoFocus
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="lab-value">{t("labs.form.value")}</Label>
<Input
id="lab-value"
inputMode="decimal"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="0.0"
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="lab-unit">{t("labs.form.unit")}</Label>
<Input
id="lab-unit"
value={unit}
onChange={(e) => setUnit(e.target.value)}
placeholder={t("labs.form.unitPlaceholder")}
maxLength={40}
required
/>
</div>
<div className="space-y-1.5 sm:col-span-2">
<Label htmlFor="lab-takenAt">{t("labs.form.takenAt")}</Label>
<DateTimeInput
id="lab-takenAt"
value={takenAt}
onChange={(e) => setTakenAt(e.target.value)}
max={defaultTakenAtValue()}
required
/>
</div>
</div>
const referenceText = selected
? formatReferenceRange(
selected.lowerBound,
selected.upperBound,
formatLabValue,
)
: "";
<fieldset className="space-y-4 rounded-lg border border-dashed p-4">
<legend className="text-muted-foreground px-1 text-xs font-medium">
{t("labs.form.optionalGroup")}
</legend>
return (
<>
<form onSubmit={handleSubmit} className="space-y-5">
<div className="space-y-1.5">
<Label htmlFor="lab-panel">{t("labs.form.panel")}</Label>
<Input
id="lab-panel"
value={panel}
onChange={(e) => setPanel(e.target.value)}
placeholder={t("labs.form.panelPlaceholder")}
maxLength={120}
/>
<Label htmlFor="lab-biomarker">{t("labs.form.biomarker")}</Label>
<Select
value={biomarkerId || undefined}
onValueChange={handleSelect}
disabled={!!lockedBiomarkerId || catalogLoading}
>
<SelectTrigger id="lab-biomarker" className="w-full">
<SelectValue
placeholder={t("labs.form.biomarkerPlaceholder")}
/>
</SelectTrigger>
<SelectContent>
{markers.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.name} · {m.unit}
</SelectItem>
))}
{!lockedBiomarkerId ? (
<SelectItem value={DEFINE_NEW}>
{t("labs.form.defineNew")}
</SelectItem>
) : null}
</SelectContent>
</Select>
{markers.length === 0 && !catalogLoading ? (
<p className="text-muted-foreground text-xs">
{t("labs.form.noBiomarkersHint")}
</p>
) : null}
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="lab-refLow">{t("labs.form.referenceLow")}</Label>
<Label htmlFor="lab-value">
{t("labs.form.value")}
{selected ? (
<span className="text-muted-foreground font-normal">
{" "}
({selected.unit})
</span>
) : null}
</Label>
<Input
id="lab-refLow"
id="lab-value"
inputMode="decimal"
value={refLow}
onChange={(e) => setRefLow(e.target.value)}
placeholder={t("labs.form.referenceLowPlaceholder")}
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="0.0"
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="lab-refHigh">{t("labs.form.referenceHigh")}</Label>
<Input
id="lab-refHigh"
inputMode="decimal"
value={refHigh}
onChange={(e) => setRefHigh(e.target.value)}
placeholder={t("labs.form.referenceHighPlaceholder")}
<Label htmlFor="lab-takenAt">{t("labs.form.takenAt")}</Label>
<DateTimeInput
id="lab-takenAt"
value={takenAt}
onChange={(e) => setTakenAt(e.target.value)}
max={defaultTakenAtValue()}
required
/>
</div>
</div>
<p className="text-muted-foreground text-xs">
{t("labs.form.referenceHint")}
</p>
{selected && referenceText ? (
<p className="text-muted-foreground text-xs">
{t("labs.referenceLabel")} {referenceText} {selected.unit}
</p>
) : null}
<div className="space-y-1.5">
<Label htmlFor="lab-note">{t("labs.form.note")}</Label>
<Textarea
@@ -197,32 +232,44 @@ export function LabForm({ onSuccess, onCancel }: LabFormProps) {
rows={2}
/>
</div>
</fieldset>
{error ? (
<p className="text-destructive text-sm" role="alert">
{error}
</p>
) : null}
<div className="flex justify-end gap-2">
{onCancel ? (
<Button
type="button"
variant="ghost"
onClick={onCancel}
disabled={submitting}
>
{t("common.cancel")}
</Button>
{error ? (
<p className="text-destructive text-sm" role="alert">
{error}
</p>
) : null}
<Button type="submit" disabled={submitting}>
{submitting ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
<div className="flex justify-end gap-2">
{onCancel ? (
<Button
type="button"
variant="ghost"
onClick={onCancel}
disabled={submitting}
>
{t("common.cancel")}
</Button>
) : null}
{t("labs.form.save")}
</Button>
</div>
</form>
<Button type="submit" disabled={submitting}>
{submitting ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("labs.form.save")}
</Button>
</div>
</form>
<ResponsiveSheet
open={defineOpen}
onOpenChange={setDefineOpen}
title={t("labs.biomarker.defineTitle")}
description={t("labs.biomarker.defineDescription")}
>
<BiomarkerForm
onSuccess={afterDefine}
onCancel={() => setDefineOpen(false)}
/>
</ResponsiveSheet>
</>
);
}
+271
View File
@@ -0,0 +1,271 @@
"use client";
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Loader2, Pencil } from "lucide-react";
import { toast } from "sonner";
import { DeleteButton } from "@/components/data-list";
import { Button } from "@/components/ui/button";
import { DateTimeInput } from "@/components/ui/date-input";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Textarea } from "@/components/ui/textarea";
import { ApiError, apiDelete, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
import { formatDateShort } from "@/lib/format";
import { formatLabValue } from "@/lib/labs/format-value";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { ReferenceRangeBadge } from "./reference-range-badge";
import type { LabResultDetailDto, LabResultDto } from "./types";
const NOTE_MAX_LENGTH = 2000;
function toDateTimeLocal(iso: string): string {
const d = new Date(iso);
const offset = d.getTimezoneOffset();
const local = new Date(d.getTime() - offset * 60 * 1000);
return local.toISOString().slice(0, 16);
}
function parseDecimal(raw: string): number | null {
const trimmed = raw.trim().replace(",", ".");
if (trimmed === "") return null;
const n = Number(trimmed);
return Number.isFinite(n) ? n : null;
}
/**
* v1.18.1 — reverse-chronological reading history for one biomarker, with
* full edit / amend / delete (L5). Edit opens a sheet that loads the
* decrypted note from the single-resource GET; delete soft-deletes with an
* Undo toast that restores via `/api/labs/restore` (the measurement pattern).
*/
export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [editingId, setEditingId] = useState<string | null>(null);
const [editValue, setEditValue] = useState("");
const [editTakenAt, setEditTakenAt] = useState("");
const [editNote, setEditNote] = useState("");
const [editLoading, setEditLoading] = useState(false);
const [editError, setEditError] = useState<string | null>(null);
function invalidate() {
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
}
async function restore(id: string) {
try {
await apiPost("/api/labs/restore", { ids: [id] });
invalidate();
toast.success(t("labs.restoredToast"));
} catch {
toast.error(t("labs.restoreError"));
}
}
const deleteMutation = useMutation({
mutationFn: (id: string) => apiDelete(`/api/labs/${id}`),
onSuccess: (_data, id) => {
invalidate();
toast.success(t("labs.deletedToast"), {
action: {
label: t("common.undo"),
onClick: () => void restore(id),
},
});
},
onError: () => toast.error(t("labs.deleteError")),
});
const updateMutation = useMutation({
mutationFn: (body: {
id: string;
value: number;
takenAt: string;
note: string | null;
}) =>
apiPut<LabResultDto>(`/api/labs/${body.id}`, {
value: body.value,
takenAt: body.takenAt,
note: body.note,
}),
onSuccess: () => {
invalidate();
toast.success(t("labs.editedToast"));
closeEdit();
},
onError: (err) => {
setEditError(
err instanceof ApiError ? err.message : t("labs.editError"),
);
},
});
async function openEdit(reading: LabResultDto) {
setEditingId(reading.id);
setEditValue(String(reading.value));
setEditTakenAt(toDateTimeLocal(reading.takenAt));
setEditNote("");
setEditError(null);
// Load the decrypted note (list rows withhold it).
setEditLoading(true);
try {
const detail = await apiGet<LabResultDetailDto>(
`/api/labs/${reading.id}`,
);
setEditNote(detail.note ?? "");
} catch {
/* leave note blank on a load miss */
} finally {
setEditLoading(false);
}
}
function closeEdit() {
if (updateMutation.isPending) return;
setEditingId(null);
setEditError(null);
}
function submitEdit(e: React.FormEvent) {
e.preventDefault();
if (!editingId) return;
const numericValue = parseDecimal(editValue);
if (numericValue === null) {
setEditError(t("labs.form.requiredError"));
return;
}
const takenDate = new Date(editTakenAt);
if (Number.isNaN(takenDate.getTime())) {
setEditError(t("labs.editTimestampError"));
return;
}
updateMutation.mutate({
id: editingId,
value: numericValue,
takenAt: takenDate.toISOString(),
note: editNote.trim() ? editNote.trim() : null,
});
}
const ordered = [...readings].sort(
(a, b) => new Date(b.takenAt).getTime() - new Date(a.takenAt).getTime(),
);
return (
<>
<ul className="divide-border divide-y">
{ordered.map((r) => (
<li
key={r.id}
className="flex items-center justify-between gap-3 py-3"
>
<div className="min-w-0">
<div className="flex flex-wrap items-baseline gap-x-2">
<span className="text-foreground font-semibold tabular-nums">
{formatLabValue(r.value)} {r.unit}
</span>
<ReferenceRangeBadge status={r.rangeStatus} />
</div>
<p className="text-muted-foreground text-xs">
{formatDateShort(r.takenAt, true)}
{r.hasNote ? ` · ${t("labs.hasNote")}` : ""}
</p>
</div>
<div className="flex items-center gap-1">
<Button
size="icon"
variant="ghost"
className="size-9"
onClick={() => void openEdit(r)}
aria-label={t("labs.editReading")}
>
<Pencil className="h-4 w-4" />
</Button>
<DeleteButton
onConfirm={() => deleteMutation.mutate(r.id)}
title={t("labs.deleteConfirmTitle")}
description={t("labs.deleteConfirmDescription")}
confirmLabel={t("labs.deleteReading")}
className="size-9"
iconClassName="h-4 w-4"
/>
</div>
</li>
))}
</ul>
<ResponsiveSheet
open={editingId !== null}
onOpenChange={(open) => {
if (!open) closeEdit();
}}
title={t("labs.editTitle")}
description={t("labs.editDescription")}
>
<form onSubmit={submitEdit} className="space-y-5">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="lab-edit-value">{t("labs.form.value")}</Label>
<Input
id="lab-edit-value"
inputMode="decimal"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="lab-edit-takenAt">
{t("labs.form.takenAt")}
</Label>
<DateTimeInput
id="lab-edit-takenAt"
value={editTakenAt}
onChange={(e) => setEditTakenAt(e.target.value)}
required
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="lab-edit-note">{t("labs.form.note")}</Label>
<Textarea
id="lab-edit-note"
value={editNote}
onChange={(e) => setEditNote(e.target.value)}
maxLength={NOTE_MAX_LENGTH}
rows={2}
disabled={editLoading}
/>
</div>
{editError ? (
<p className="text-destructive text-sm" role="alert">
{editError}
</p>
) : null}
<div className="flex justify-end gap-2">
<Button
type="button"
variant="ghost"
onClick={closeEdit}
disabled={updateMutation.isPending}
>
{t("common.cancel")}
</Button>
<Button type="submit" disabled={updateMutation.isPending}>
{updateMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("common.save")}
</Button>
</div>
</form>
</ResponsiveSheet>
</>
);
}
+101 -95
View File
@@ -1,24 +1,23 @@
"use client";
import { useMemo } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { FlaskConical } from "lucide-react";
import { toast } from "sonner";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { ChevronRight, FlaskConical } from "lucide-react";
import { DeleteButton } from "@/components/data-list";
import { formatReferenceRange } from "@/lib/labs/reference-range";
import { Button } from "@/components/ui/button";
import {
Card,
CardAction,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { Skeleton } from "@/components/ui/skeleton";
import { apiDelete, apiGet } from "@/lib/api/api-fetch";
import { apiGet } from "@/lib/api/api-fetch";
import { formatDate } from "@/lib/format";
import { formatReferenceRange } from "@/lib/labs/reference-range";
import { formatLabValue } from "@/lib/labs/format-value";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -26,34 +25,44 @@ import { LabTrendSparkline } from "./lab-trend-sparkline";
import { ReferenceRangeBadge } from "./reference-range-badge";
import type { LabResultDto, LabResultListResponse } from "./types";
interface AnalyteGroup {
interface MarkerGroup {
/** Stable group key: the biomarker id, or `analyte:<lower>` for legacy rows. */
key: string;
/** Link target when the group is catalog-linked; null for legacy rows. */
biomarkerId: string | null;
analyte: string;
panel: string | null;
unit: string;
/** All readings for this analyte, oldest → newest. */
readings: LabResultDto[];
latest: LabResultDto;
}
/** Reduce the flat result list into per-analyte groups (case-insensitive). */
function groupByAnalyte(results: LabResultDto[]): AnalyteGroup[] {
/**
* Group readings by their linked biomarker (or, for legacy un-linked rows,
* case-insensitively by analyte). A catalog-linked group deep-links to its
* detail chart; legacy groups render inert until the backfill links them.
*/
function groupReadings(results: LabResultDto[]): MarkerGroup[] {
const byKey = new Map<string, LabResultDto[]>();
for (const r of results) {
const key = r.analyte.toLowerCase();
const existing = byKey.get(key);
if (existing) existing.push(r);
const key = r.biomarkerId
? `bm:${r.biomarkerId}`
: `analyte:${r.analyte.toLowerCase()}`;
const bucket = byKey.get(key);
if (bucket) bucket.push(r);
else byKey.set(key, [r]);
}
const groups: AnalyteGroup[] = [];
for (const readings of byKey.values()) {
// Ascending by takenAt so the sparkline reads left→right in time and
// the last element is the latest.
const groups: MarkerGroup[] = [];
for (const [key, readings] of byKey.entries()) {
const ordered = [...readings].sort(
(a, b) =>
new Date(a.takenAt).getTime() - new Date(b.takenAt).getTime(),
);
const latest = ordered[ordered.length - 1];
groups.push({
key,
biomarkerId: latest.biomarkerId,
analyte: latest.analyte,
panel: latest.panel,
unit: latest.unit,
@@ -61,7 +70,6 @@ function groupByAnalyte(results: LabResultDto[]): AnalyteGroup[] {
latest,
});
}
// Group order: most-recently-updated analyte first.
return groups.sort(
(a, b) =>
new Date(b.latest.takenAt).getTime() -
@@ -69,16 +77,11 @@ function groupByAnalyte(results: LabResultDto[]): AnalyteGroup[] {
);
}
function formatValue(value: number): string {
// Trim trailing zeros for whole numbers; keep up to 2 decimals otherwise.
return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(2)));
}
export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const listKey = queryKeys.labResultsList({
biomarkerId: undefined,
analyte: undefined,
panel: undefined,
from: undefined,
@@ -89,25 +92,12 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
const { data, isLoading, isError } = useQuery({
queryKey: listKey,
queryFn: async () => {
const res = await apiGet<LabResultListResponse>(
"/api/labs?limit=500&sortDir=desc",
);
return res;
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => apiDelete(`/api/labs/${id}`),
onSuccess: () => {
toast.success(t("labs.deletedToast"));
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
},
onError: () => toast.error(t("labs.deleteError")),
queryFn: () =>
apiGet<LabResultListResponse>("/api/labs?limit=500&sortDir=desc"),
});
const groups = useMemo(
() => groupByAnalyte(data?.results ?? []),
() => groupReadings(data?.results ?? []),
[data?.results],
);
@@ -154,60 +144,76 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
return (
<div className="space-y-3">
{groups.map((group) => (
<Card key={group.analyte.toLowerCase()}>
<CardHeader>
<CardTitle className="flex flex-wrap items-baseline gap-x-2 gap-y-1 text-sm">
<span className="font-medium">{group.analyte}</span>
{group.panel ? (
<span className="text-muted-foreground text-xs font-normal">
{group.panel}
</span>
) : null}
<ReferenceRangeBadge status={group.latest.rangeStatus} />
</CardTitle>
<CardAction className="flex items-center gap-3">
<LabTrendSparkline values={group.readings.map((r) => r.value)} />
<DeleteButton
onConfirm={() => deleteMutation.mutate(group.latest.id)}
title={t("labs.deleteConfirmTitle")}
description={t("labs.deleteConfirmDescription")}
confirmLabel={t("labs.deleteLatest")}
className="size-9"
iconClassName="h-4 w-4"
/>
</CardAction>
</CardHeader>
<CardContent>
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 text-sm">
<span className="text-foreground font-semibold tabular-nums">
{formatValue(group.latest.value)} {group.latest.unit}
</span>
{group.latest.referenceLow !== null ||
group.latest.referenceHigh !== null ? (
<span className="text-xs">
{t("labs.referenceLabel")}{" "}
{formatReferenceRange(
group.latest.referenceLow,
group.latest.referenceHigh,
formatValue,
)}
</span>
) : null}
<span className="text-xs">
{formatDate(group.latest.takenAt)}
</span>
{group.readings.length > 1 ? (
<span className="text-xs">
{t("labs.readingsCount", {
count: group.readings.length,
})}
</span>
) : null}
</div>
</CardContent>
</Card>
))}
{groups.map((group) => {
const body = (
<>
<CardHeader>
<CardTitle className="flex flex-wrap items-baseline gap-x-2 gap-y-1 text-sm">
<span className="font-medium">{group.analyte}</span>
{group.panel ? (
<span className="text-muted-foreground text-xs font-normal">
{group.panel}
</span>
) : null}
<ReferenceRangeBadge status={group.latest.rangeStatus} />
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between gap-3">
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 text-sm">
<span className="text-foreground font-semibold tabular-nums">
{formatLabValue(group.latest.value)} {group.latest.unit}
</span>
{group.latest.referenceLow !== null ||
group.latest.referenceHigh !== null ? (
<span className="text-xs">
{t("labs.referenceLabel")}{" "}
{formatReferenceRange(
group.latest.referenceLow,
group.latest.referenceHigh,
formatLabValue,
)}
</span>
) : null}
<span className="text-xs">
{formatDate(group.latest.takenAt)}
</span>
{group.readings.length > 1 ? (
<span className="text-xs">
{t("labs.readingsCount", {
count: group.readings.length,
})}
</span>
) : null}
</div>
<div className="flex items-center gap-2">
<LabTrendSparkline
values={group.readings.map((r) => r.value)}
/>
{group.biomarkerId ? (
<ChevronRight className="text-muted-foreground h-4 w-4 shrink-0" />
) : null}
</div>
</div>
</CardContent>
</>
);
// Catalog-linked groups deep-link to the per-biomarker detail chart.
// Legacy un-linked groups render as a plain card until the backfill
// links them (or the user re-adds via the structured path).
return group.biomarkerId ? (
<Link
key={group.key}
href={`/labs/${group.biomarkerId}`}
className="block"
>
<Card className="hover:bg-muted/40 transition-colors">{body}</Card>
</Link>
) : (
<Card key={group.key}>{body}</Card>
);
})}
</div>
);
}
+25
View File
@@ -3,6 +3,8 @@ import type { ReferenceRangeStatus } from "@/lib/validations/labs";
/** A lab-result row as the list / create endpoints serialise it. */
export interface LabResultDto {
id: string;
/** v1.18.1 — link to the catalog marker (null for legacy free-text rows). */
biomarkerId: string | null;
panel: string | null;
analyte: string;
value: number;
@@ -17,7 +19,30 @@ export interface LabResultDto {
updatedAt: string;
}
/** The single-resource detail DTO — carries the decrypted note. */
export interface LabResultDetailDto extends Omit<LabResultDto, "hasNote"> {
note: string | null;
}
export interface LabResultListResponse {
results: LabResultDto[];
meta: { total: number; limit: number; offset: number };
}
/** A user-scoped Biomarker catalog entry as the API serialises it. */
export interface BiomarkerDto {
id: string;
name: string;
unit: string;
lowerBound: number | null;
upperBound: number | null;
panel: string | null;
hasContext: boolean;
context: string | null;
createdAt: string;
updatedAt: string;
}
export interface BiomarkerListResponse {
biomarkers: BiomarkerDto[];
}