From 04c95e5ba1ba7d93febf22b9ad5526f180e406ed Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Tue, 16 Jun 2026 21:06:08 +0200
Subject: [PATCH] feat(labs): structured entry, per-biomarker chart, and
edit/delete UI
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Rework the Labs surface to match the rest of the app:
- Add a biomarker manager on the Labs page (define/edit/delete markers,
seed from a common panel) — the catalog lives with Labs, not Settings.
- Replace the free-text add form with a structured one: pick a biomarker,
enter the value against its known unit/range; '+ define new' inline.
- New /labs/[biomarkerId] detail page with a proper dashboard-style chart
(Recharts line + shaded reference band + dashed bounds, range tabs,
tooltip) replacing the 72x20 sparkline, plus the reading history with
full edit/amend/delete and a delete-Undo toast.
- List cards group by linked marker and deep-link to the detail chart.
The reference band uses a muted token fill, never an alarm colour; the
neutral ReferenceRangeBadge is reused for the in/out-of-range verdict.
---
src/app/labs/[biomarkerId]/page.tsx | 51 ++++
src/app/labs/page.tsx | 41 ++-
src/components/labs/biomarker-form.tsx | 281 ++++++++++++++++++
src/components/labs/biomarker-manager.tsx | 177 +++++++++++
src/components/labs/lab-biomarker-chart.tsx | 224 ++++++++++++++
src/components/labs/lab-biomarker-detail.tsx | 181 ++++++++++++
src/components/labs/lab-form.tsx | 295 +++++++++++--------
src/components/labs/lab-history-list.tsx | 271 +++++++++++++++++
src/components/labs/lab-list.tsx | 196 ++++++------
src/components/labs/types.ts | 25 ++
10 files changed, 1514 insertions(+), 228 deletions(-)
create mode 100644 src/app/labs/[biomarkerId]/page.tsx
create mode 100644 src/components/labs/biomarker-form.tsx
create mode 100644 src/components/labs/biomarker-manager.tsx
create mode 100644 src/components/labs/lab-biomarker-chart.tsx
create mode 100644 src/components/labs/lab-biomarker-detail.tsx
create mode 100644 src/components/labs/lab-history-list.tsx
diff --git a/src/app/labs/[biomarkerId]/page.tsx b/src/app/labs/[biomarkerId]/page.tsx
new file mode 100644
index 000000000..29189ca39
--- /dev/null
+++ b/src/app/labs/[biomarkerId]/page.tsx
@@ -0,0 +1,51 @@
+"use client";
+
+/**
+ * v1.18.1 — per-biomarker detail page (`/labs/[biomarkerId]`).
+ *
+ * Owns auth + the back link and hands the marker id to
+ * ``, which carries the heading, the dashboard-style
+ * chart with the reference band, and the editable reading history.
+ */
+import { use, useEffect } from "react";
+import { useRouter } from "next/navigation";
+import { Loader2 } from "lucide-react";
+
+import { LabBiomarkerDetail } from "@/components/labs/lab-biomarker-detail";
+import { BackLink } from "@/components/ui/back-link";
+import { useAuth } from "@/hooks/use-auth";
+import { useMounted } from "@/hooks/use-mounted";
+import { useTranslations } from "@/lib/i18n/context";
+
+export default function LabBiomarkerDetailPage({
+ params,
+}: {
+ params: Promise<{ biomarkerId: string }>;
+}) {
+ const { biomarkerId } = use(params);
+ const { isAuthenticated, isLoading } = useAuth();
+ const mounted = useMounted();
+ const router = useRouter();
+ const { t } = useTranslations();
+
+ useEffect(() => {
+ if (!isLoading && !isAuthenticated) {
+ router.push("/auth/login");
+ }
+ }, [isLoading, isAuthenticated, router]);
+
+ if (!mounted || isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ );
+}
diff --git a/src/app/labs/page.tsx b/src/app/labs/page.tsx
index 2030d0510..d0b685f1c 100644
--- a/src/app/labs/page.tsx
+++ b/src/app/labs/page.tsx
@@ -3,8 +3,9 @@
import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
-import { Loader2, Plus } from "lucide-react";
+import { FlaskConical, Loader2, Plus } from "lucide-react";
+import { BiomarkerManager } from "@/components/labs/biomarker-manager";
import { LabForm } from "@/components/labs/lab-form";
import { LabList } from "@/components/labs/lab-list";
import { Button } from "@/components/ui/button";
@@ -23,6 +24,7 @@ export default function LabsPage() {
const queryClient = useQueryClient();
const { t } = useTranslations();
const [dialogOpen, setDialogOpen] = useState(false);
+ const [manageOpen, setManageOpen] = useState(false);
const refreshVisible = useCallback(
() => queryClient.invalidateQueries({ type: "active" }),
@@ -30,7 +32,7 @@ export default function LabsPage() {
);
const pull = usePullToRefresh({
onRefresh: refreshVisible,
- disabled: dialogOpen,
+ disabled: dialogOpen || manageOpen,
});
useEffect(() => {
@@ -59,13 +61,25 @@ export default function LabsPage() {
{t("labs.subtitle")}
- setDialogOpen(true)}
- className="min-h-11 shrink-0 sm:min-h-9"
- >
-
- {t("labs.addResult")}
-
+
+
setManageOpen(true)}
+ className="min-h-11 sm:min-h-9"
+ >
+
+
+ {t("labs.biomarker.manage")}
+
+
+
setDialogOpen(true)}
+ className="min-h-11 sm:min-h-9"
+ >
+
+ {t("labs.addResult")}
+
+
+
+
+
+
setDialogOpen(true)} />
);
diff --git a/src/components/labs/biomarker-form.tsx b/src/components/labs/biomarker-form.tsx
new file mode 100644
index 000000000..e814ef717
--- /dev/null
+++ b/src/components/labs/biomarker-form.tsx
@@ -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 `` 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(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(`/api/biomarkers/${existing.id}`, body)
+ : await apiPost("/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 (
+
+ );
+}
diff --git a/src/components/labs/biomarker-manager.tsx b/src/components/labs/biomarker-manager.tsx
new file mode 100644
index 000000000..b3dd76279
--- /dev/null
+++ b/src/components/labs/biomarker-manager.tsx
@@ -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(null);
+
+ const { data, isLoading, isError } = useQuery({
+ queryKey: queryKeys.biomarkers(),
+ queryFn: () => apiGet("/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 (
+
+
+
+ {t("labs.biomarker.managerDescription")}
+
+
+
+ {t("labs.biomarker.define")}
+
+
+
+ {isLoading ? (
+
+ {Array.from({ length: 3 }, (_, i) => (
+
+ ))}
+
+ ) : isError ? (
+
+ {t("labs.biomarker.loadError")}
+
+ ) : markers.length === 0 ? (
+
}
+ title={t("labs.biomarker.emptyTitle")}
+ description={t("labs.biomarker.emptyDescription")}
+ action={
+
{t("labs.biomarker.defineFirst")}
+ }
+ />
+ ) : (
+
+ {markers.map((marker) => {
+ const range = formatReferenceRange(
+ marker.lowerBound,
+ marker.upperBound,
+ formatLabValue,
+ );
+ return (
+
+
+
+
+
+
+ {marker.name}
+
+
+ {marker.unit}
+
+
+
+ {range
+ ? `${t("labs.referenceLabel")} ${range} ${marker.unit}`
+ : t("labs.biomarker.noRange")}
+ {marker.panel ? ` · ${marker.panel}` : ""}
+
+
+
+
openEdit(marker)}
+ aria-label={t("labs.biomarker.edit")}
+ >
+
+
+
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"
+ />
+
+
+
+
+ );
+ })}
+
+ )}
+
+
+ setFormOpen(false)}
+ />
+
+
+ );
+}
diff --git a/src/components/labs/lab-biomarker-chart.tsx b/src/components/labs/lab-biomarker-chart.tsx
new file mode 100644
index 000000000..5890c9048
--- /dev/null
+++ b/src/components/labs/lab-biomarker-chart.tsx
@@ -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("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(() => {
+ 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 (
+
+
+ {RANGE_DAYS.map((r) => (
+ setRange(r.key)}
+ >
+ {t(`labs.chart.range.${r.key}`)}
+
+ ))}
+
+
+ {points.length === 0 ? (
+
+ {t("labs.chart.empty")}
+
+ ) : (
+
+
+
+
+ formatDateShort(new Date(ts))}
+ tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
+ stroke="var(--border)"
+ minTickGap={32}
+ />
+ 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 ? (
+
+ ) : null}
+ {lowerBound != null ? (
+
+ ) : null}
+ {upperBound != null ? (
+
+ ) : null}
+ {
+ const active = props.active ?? false;
+ const payload = props.payload as
+ | ReadonlyArray<{ payload?: ChartPoint }>
+ | undefined;
+ const point = payload?.[0]?.payload;
+ if (!active || !point) {
+ return ;
+ }
+ const rows: RichTooltipRow[] = [
+ {
+ name: t("labs.chart.valueLabel"),
+ value: `${formatLabValue(point.value)} ${unit}`,
+ color: primary,
+ },
+ ];
+ return (
+
+ );
+ }}
+ />
+
+
+
+
+ )}
+
+ );
+}
diff --git a/src/components/labs/lab-biomarker-detail.tsx b/src/components/labs/lab-biomarker-detail.tsx
new file mode 100644
index 000000000..f23ad6452
--- /dev/null
+++ b/src/components/labs/lab-biomarker-detail.tsx
@@ -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(`/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(
+ `/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 (
+
+ {t("labs.loadError")}
+
+ );
+ }
+
+ const latestStatus = latest
+ ? classifyReferenceRange(
+ latest.value,
+ marker?.lowerBound ?? null,
+ marker?.upperBound ?? null,
+ )
+ : "unknown";
+
+ return (
+
+
+
+
+ {marker?.name ?? }
+
+ {marker?.unit ? (
+
+ {marker.unit}
+ {marker.panel ? ` · ${marker.panel}` : ""}
+
+ ) : null}
+
+
setAddOpen(true)} className="shrink-0">
+
+ {t("labs.addResult")}
+
+
+
+ {latest ? (
+
+
+ {formatLabValue(latest.value)}{" "}
+
+ {marker?.unit ?? latest.unit}
+
+
+
+
+ ) : null}
+
+ {marker?.context ? (
+
{marker.context}
+ ) : null}
+
+
+
+ {isLoading ? (
+
+ ) : readings.length === 0 ? (
+ }
+ title={t("labs.detail.emptyTitle")}
+ description={t("labs.detail.emptyDescription")}
+ action={
+ setAddOpen(true)}>
+ {t("labs.addResult")}
+
+ }
+ />
+ ) : (
+
+ )}
+
+
+
+ {readings.length > 0 ? (
+
+
{t("labs.detail.history")}
+
+
+
+
+
+
+ ) : null}
+
+
+ setAddOpen(false)}
+ />
+
+
+ );
+}
diff --git a/src/components/labs/lab-form.tsx b/src/components/labs/lab-form.tsx
index e49a52d66..04aebe218 100644
--- a/src/components/labs/lab-form.tsx
+++ b/src/components/labs/lab-form.tsx
@@ -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("/api/biomarkers"),
+ });
+
+ const [biomarkerId, setBiomarkerId] = useState(
+ 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(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("/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 (
-
-
-
- {t("labs.form.analyte")}
- setAnalyte(e.target.value)}
- placeholder={t("labs.form.analytePlaceholder")}
- maxLength={120}
- autoFocus
- required
- />
-
-
- {t("labs.form.value")}
- setValue(e.target.value)}
- placeholder="0.0"
- required
- />
-
-
- {t("labs.form.unit")}
- setUnit(e.target.value)}
- placeholder={t("labs.form.unitPlaceholder")}
- maxLength={40}
- required
- />
-
-
- {t("labs.form.takenAt")}
- setTakenAt(e.target.value)}
- max={defaultTakenAtValue()}
- required
- />
-
-
+ const referenceText = selected
+ ? formatReferenceRange(
+ selected.lowerBound,
+ selected.upperBound,
+ formatLabValue,
+ )
+ : "";
-
-
- {t("labs.form.optionalGroup")}
-
+ return (
+ <>
+
-
{t("labs.form.panel")}
-
setPanel(e.target.value)}
- placeholder={t("labs.form.panelPlaceholder")}
- maxLength={120}
- />
+
{t("labs.form.biomarker")}
+
+
+
+
+
+ {markers.map((m) => (
+
+ {m.name} · {m.unit}
+
+ ))}
+ {!lockedBiomarkerId ? (
+
+ {t("labs.form.defineNew")}
+
+ ) : null}
+
+
+ {markers.length === 0 && !catalogLoading ? (
+
+ {t("labs.form.noBiomarkersHint")}
+
+ ) : null}
+
-
- {t("labs.form.referenceHint")}
-
+
+ {selected && referenceText ? (
+
+ {t("labs.referenceLabel")} {referenceText} {selected.unit}
+
+ ) : null}
+
{t("labs.form.note")}
-
- {error ? (
-
- {error}
-
- ) : null}
-
-
- {onCancel ? (
-
- {t("common.cancel")}
-
+ {error ? (
+
+ {error}
+
) : null}
-
- {submitting ? (
-
+
+
+ {onCancel ? (
+
+ {t("common.cancel")}
+
) : null}
- {t("labs.form.save")}
-
-
-
+
+ {submitting ? (
+
+ ) : null}
+ {t("labs.form.save")}
+
+
+
+
+
+ setDefineOpen(false)}
+ />
+
+ >
);
}
diff --git a/src/components/labs/lab-history-list.tsx b/src/components/labs/lab-history-list.tsx
new file mode 100644
index 000000000..cdbeb0b0c
--- /dev/null
+++ b/src/components/labs/lab-history-list.tsx
@@ -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(null);
+ const [editValue, setEditValue] = useState("");
+ const [editTakenAt, setEditTakenAt] = useState("");
+ const [editNote, setEditNote] = useState("");
+ const [editLoading, setEditLoading] = useState(false);
+ const [editError, setEditError] = useState(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(`/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(
+ `/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 (
+ <>
+
+ {ordered.map((r) => (
+
+
+
+
+ {formatLabValue(r.value)} {r.unit}
+
+
+
+
+ {formatDateShort(r.takenAt, true)}
+ {r.hasNote ? ` · ${t("labs.hasNote")}` : ""}
+
+
+
+
void openEdit(r)}
+ aria-label={t("labs.editReading")}
+ >
+
+
+
deleteMutation.mutate(r.id)}
+ title={t("labs.deleteConfirmTitle")}
+ description={t("labs.deleteConfirmDescription")}
+ confirmLabel={t("labs.deleteReading")}
+ className="size-9"
+ iconClassName="h-4 w-4"
+ />
+
+
+ ))}
+
+
+ {
+ if (!open) closeEdit();
+ }}
+ title={t("labs.editTitle")}
+ description={t("labs.editDescription")}
+ >
+
+
+
+ {t("labs.form.value")}
+ setEditValue(e.target.value)}
+ required
+ />
+
+
+
+ {t("labs.form.takenAt")}
+
+ setEditTakenAt(e.target.value)}
+ required
+ />
+
+
+
+ {t("labs.form.note")}
+ setEditNote(e.target.value)}
+ maxLength={NOTE_MAX_LENGTH}
+ rows={2}
+ disabled={editLoading}
+ />
+
+ {editError ? (
+
+ {editError}
+
+ ) : null}
+
+
+ {t("common.cancel")}
+
+
+ {updateMutation.isPending ? (
+
+ ) : null}
+ {t("common.save")}
+
+
+
+
+ >
+ );
+}
diff --git a/src/components/labs/lab-list.tsx b/src/components/labs/lab-list.tsx
index bf24b1201..0d2cf6c28 100644
--- a/src/components/labs/lab-list.tsx
+++ b/src/components/labs/lab-list.tsx
@@ -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:` 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();
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(
- "/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("/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 (
- {groups.map((group) => (
-
-
-
- {group.analyte}
- {group.panel ? (
-
- {group.panel}
-
- ) : null}
-
-
-
- r.value)} />
- 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"
- />
-
-
-
-
-
- {formatValue(group.latest.value)} {group.latest.unit}
-
- {group.latest.referenceLow !== null ||
- group.latest.referenceHigh !== null ? (
-
- {t("labs.referenceLabel")}{" "}
- {formatReferenceRange(
- group.latest.referenceLow,
- group.latest.referenceHigh,
- formatValue,
- )}
-
- ) : null}
-
- {formatDate(group.latest.takenAt)}
-
- {group.readings.length > 1 ? (
-
- {t("labs.readingsCount", {
- count: group.readings.length,
- })}
-
- ) : null}
-
-
-
- ))}
+ {groups.map((group) => {
+ const body = (
+ <>
+
+
+ {group.analyte}
+ {group.panel ? (
+
+ {group.panel}
+
+ ) : null}
+
+
+
+
+
+
+
+ {formatLabValue(group.latest.value)} {group.latest.unit}
+
+ {group.latest.referenceLow !== null ||
+ group.latest.referenceHigh !== null ? (
+
+ {t("labs.referenceLabel")}{" "}
+ {formatReferenceRange(
+ group.latest.referenceLow,
+ group.latest.referenceHigh,
+ formatLabValue,
+ )}
+
+ ) : null}
+
+ {formatDate(group.latest.takenAt)}
+
+ {group.readings.length > 1 ? (
+
+ {t("labs.readingsCount", {
+ count: group.readings.length,
+ })}
+
+ ) : null}
+
+
+ r.value)}
+ />
+ {group.biomarkerId ? (
+
+ ) : null}
+
+
+
+ >
+ );
+
+ // 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 ? (
+
+
{body}
+
+ ) : (
+
{body}
+ );
+ })}
);
}
diff --git a/src/components/labs/types.ts b/src/components/labs/types.ts
index 0acce2953..315436fb7 100644
--- a/src/components/labs/types.ts
+++ b/src/components/labs/types.ts
@@ -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 {
+ 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[];
+}