feat(labs): add the Laborwerte page, navigation, and OpenAPI contract

A dedicated /labs page hosts manual entry and a per-analyte view: the
four essentials (biomarker, value, unit, date) stay visible, the
reference range, panel grouping and note sit under an optional section.
Results group by analyte with a small inline trend once an analyte has
two or more readings. The reference-range badge is deliberately neutral
and calm — an out-of-range value reads as a quiet directional marker, not
an alarming red.

Adds the labs section toggle to the health-record export panel, the /labs
nav entries, the /api/labs OpenAPI routes, and the labs copy across all
six locales.
This commit is contained in:
Marc-André Bombeck committed 2026-06-14 23:39:52 +02:00
1 parent fbecb6b5c8
commit 72fb51c257
19 files changed
+1690 -24

No files matched your search

+228
View File
@@ -0,0 +1,228 @@
"use client";
import { useState } from "react";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
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 { Textarea } from "@/components/ui/textarea";
import { ApiError, apiPost } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import type { LabResultDto } from "./types";
const NOTE_MAX_LENGTH = 2000;
function defaultTakenAtValue() {
const now = new Date();
const offset = now.getTimezoneOffset();
const local = new Date(now.getTime() - offset * 60 * 1000);
return local.toISOString().slice(0, 16);
}
/** 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 LabFormProps {
onSuccess?: (created: LabResultDto) => void;
onCancel?: () => void;
}
/**
* v1.17.1 — manual 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.
*/
export function LabForm({ onSuccess, onCancel }: LabFormProps) {
const { t } = useTranslations();
const [analyte, setAnalyte] = useState("");
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);
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"));
return;
}
const low = parseDecimal(refLow);
const high = parseDecimal(refHigh);
if (low !== null && high !== null && low > high) {
setError(t("labs.form.rangeOrderError"));
return;
}
setSubmitting(true);
try {
const created = await apiPost<LabResultDto>("/api/labs", {
analyte: analyte.trim(),
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"));
onSuccess?.(created);
} catch (err) {
const message =
err instanceof ApiError ? err.message : t("labs.form.saveError");
setError(message);
} finally {
setSubmitting(false);
}
}
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>
<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>
<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}
/>
</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>
<Input
id="lab-refLow"
inputMode="decimal"
value={refLow}
onChange={(e) => setRefLow(e.target.value)}
placeholder={t("labs.form.referenceLowPlaceholder")}
/>
</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")}
/>
</div>
</div>
<p className="text-muted-foreground text-xs">
{t("labs.form.referenceHint")}
</p>
<div className="space-y-1.5">
<Label htmlFor="lab-note">{t("labs.form.note")}</Label>
<Textarea
id="lab-note"
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder={t("labs.form.notePlaceholder")}
maxLength={NOTE_MAX_LENGTH}
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>
) : null}
<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>
);
}
+199
View File
@@ -0,0 +1,199 @@
"use client";
import { useMemo } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { FlaskConical, Loader2, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { apiDelete, apiGet } from "@/lib/api/api-fetch";
import { formatDate } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { LabTrendSparkline } from "./lab-trend-sparkline";
import { ReferenceRangeBadge } from "./reference-range-badge";
import type { LabResultDto, LabResultListResponse } from "./types";
interface AnalyteGroup {
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[] {
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);
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 ordered = [...readings].sort(
(a, b) =>
new Date(a.takenAt).getTime() - new Date(b.takenAt).getTime(),
);
const latest = ordered[ordered.length - 1];
groups.push({
analyte: latest.analyte,
panel: latest.panel,
unit: latest.unit,
readings: ordered,
latest,
});
}
// Group order: most-recently-updated analyte first.
return groups.sort(
(a, b) =>
new Date(b.latest.takenAt).getTime() -
new Date(a.latest.takenAt).getTime(),
);
}
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({
analyte: undefined,
panel: undefined,
from: undefined,
to: undefined,
page: 0,
sortDir: "desc",
});
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")),
});
const groups = useMemo(
() => groupByAnalyte(data?.results ?? []),
[data?.results],
);
if (isLoading) {
return (
<div className="flex h-48 items-center justify-center">
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
</div>
);
}
if (isError) {
return (
<p className="text-destructive py-8 text-center text-sm">
{t("labs.loadError")}
</p>
);
}
if (groups.length === 0) {
return (
<EmptyState
icon={<FlaskConical className="size-6" />}
title={t("labs.emptyTitle")}
description={t("labs.emptyDescription")}
action={
onAddFirst ? (
<Button onClick={onAddFirst}>{t("labs.addFirst")}</Button>
) : undefined
}
/>
);
}
return (
<div className="space-y-3">
{groups.map((group) => (
<Card key={group.analyte.toLowerCase()}>
<CardContent className="flex flex-col gap-3 py-4 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
<span className="font-medium">{group.analyte}</span>
{group.panel ? (
<span className="text-muted-foreground text-xs">
{group.panel}
</span>
) : null}
<ReferenceRangeBadge status={group.latest.rangeStatus} />
</div>
<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")}{" "}
{group.latest.referenceLow !== null &&
group.latest.referenceHigh !== null
? `${formatValue(group.latest.referenceLow)}–${formatValue(group.latest.referenceHigh)}`
: group.latest.referenceHigh !== null
? `≤ ${formatValue(group.latest.referenceHigh)}`
: `≥ ${formatValue(group.latest.referenceLow as number)}`}
</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>
<div className="flex items-center gap-3 self-end sm:self-center">
<LabTrendSparkline
values={group.readings.map((r) => r.value)}
/>
<Button
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-destructive"
aria-label={t("labs.deleteLatest")}
disabled={deleteMutation.isPending}
onClick={() => deleteMutation.mutate(group.latest.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
);
}
@@ -0,0 +1,61 @@
"use client";
/**
* v1.17.1 — minimal inline trend sparkline for an analyte's readings.
*
* A dependency-free SVG polyline (the project defers Recharts for tiny
* inline trends; this matches the doctor-report PDF's vector-polyline
* approach). Rendered only when an analyte has ≥ 2 readings. Stroke uses
* the neutral `currentColor` so it inherits the calm muted-foreground tone
* of its row — no status colour, in keeping with the no-alarming-colour
* ethos.
*
* `values` are passed oldest → newest.
*/
export function LabTrendSparkline({
values,
width = 72,
height = 20,
}: {
values: number[];
width?: number;
height?: number;
}) {
if (values.length < 2) return null;
const min = Math.min(...values);
const max = Math.max(...values);
const span = max - min || 1;
const stepX = width / (values.length - 1);
const pad = 2;
const usableH = height - pad * 2;
const points = values
.map((v, i) => {
const x = i * stepX;
// Invert Y so a higher value sits higher on screen.
const y = pad + usableH - ((v - min) / span) * usableH;
return `${x.toFixed(1)},${y.toFixed(1)}`;
})
.join(" ");
return (
<svg
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
className="text-muted-foreground/70 overflow-visible"
aria-hidden
role="presentation"
>
<polyline
points={points}
fill="none"
stroke="currentColor"
strokeWidth={1.25}
strokeLinejoin="round"
strokeLinecap="round"
/>
</svg>
);
}
@@ -0,0 +1,52 @@
"use client";
import { ArrowDown, ArrowUp, Minus } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { useTranslations } from "@/lib/i18n/context";
import type { ReferenceRangeStatus } from "@/lib/validations/labs";
/**
* v1.17.1 — reference-range badge.
*
* Renders the server-computed `rangeStatus` as a calm, informative marker.
* The project's no-alarming-colour ethos is a hard rule here: an
* out-of-range value is NOT painted red. It reads as a neutral `secondary`
* badge with a quiet direction arrow (↓ below / ↑ above), exactly the same
* weight as the in-range and unknown states. The number leaving the
* reference window is information a clinician scans — not an alarm to the
* user, who may be perfectly aware of (and managing) the value.
*
* `unknown` (the lab reported no usable bounds) renders nothing — there is
* no verdict to show, and an empty badge would be noise.
*/
export function ReferenceRangeBadge({
status,
}: {
status: ReferenceRangeStatus;
}) {
const { t } = useTranslations();
if (status === "unknown") return null;
if (status === "in-range") {
return (
<Badge variant="outline" className="text-muted-foreground">
<Minus aria-hidden />
{t("labs.range.inRange")}
</Badge>
);
}
// Below / above — both neutral `secondary`, distinguished only by the
// arrow direction and label. No red, no amber.
const Icon = status === "below" ? ArrowDown : ArrowUp;
const label =
status === "below" ? t("labs.range.below") : t("labs.range.above");
return (
<Badge variant="secondary">
<Icon aria-hidden />
{label}
</Badge>
);
}
+23
View File
@@ -0,0 +1,23 @@
import type { ReferenceRangeStatus } from "@/lib/validations/labs";
/** A lab-result row as the list / create endpoints serialise it. */
export interface LabResultDto {
id: string;
panel: string | null;
analyte: string;
value: number;
unit: string;
referenceLow: number | null;
referenceHigh: number | null;
takenAt: string;
source: string;
hasNote: boolean;
rangeStatus: ReferenceRangeStatus;
createdAt: string;
updatedAt: string;
}
export interface LabResultListResponse {
results: LabResultDto[];
meta: { total: number; limit: number; offset: number };
}
+2
View File
@@ -6,6 +6,7 @@ import {
Bug,
Droplets,
Dumbbell,
FlaskConical,
Home,
Lightbulb,
MoreHorizontal,
@@ -70,6 +71,7 @@ const PRIMARY_RIGHT: ReadonlyArray<NavLink> = [
const MORE_HUB: ReadonlyArray<NavLink> = [
{ href: "/measurements", tKey: "nav.measurements", icon: Activity },
{ href: "/mood", tKey: "nav.mood", icon: Waves },
{ href: "/labs", tKey: "nav.labs", icon: FlaskConical },
{ href: "/insights/workouts", tKey: "nav.workouts", icon: Dumbbell },
{ href: "/achievements", tKey: "nav.achievements", icon: Trophy },
{ href: "/notifications", tKey: "nav.notifications", icon: Bell },
+4
View File
@@ -8,6 +8,7 @@ import {
ChevronsLeft,
ChevronsRight,
Droplets,
FlaskConical,
Home,
Lightbulb,
LogOut,
@@ -66,6 +67,9 @@ const navItems = [
icon: Pill,
tourId: "nav-medications",
},
// v1.17.1 — structured lab-result store. Pairs with the Vorsorge
// annual-blood-panel reminder (which records its result here).
{ href: "/labs", tKey: "nav.labs", icon: FlaskConical, tourId: "nav-labs" },
// v1.4.15 Phase B5: `tourId` values match `data-tour-id` lookups
// performed by the onboarding tour. Keep these stable — renaming
// them silently breaks the spotlight cutout for that step.
@@ -56,6 +56,7 @@ interface SectionState {
compliance: boolean;
mood: boolean;
bmi: boolean;
labs: boolean;
}
const DEFAULT_SECTIONS: SectionState = {
@@ -76,6 +77,7 @@ const DEFAULT_SECTIONS: SectionState = {
compliance: true,
mood: false, // privacy default
bmi: true,
labs: true,
};
function buildSelectionSections(s: SectionState) {
@@ -102,6 +104,7 @@ function buildSelectionSections(s: SectionState) {
medications: { list: s.medList, compliance: s.compliance },
mood: s.mood,
bmi: s.bmi,
labs: s.labs,
};
}
@@ -411,6 +414,11 @@ export function HealthRecordExportPanel() {
checked={sections.mood}
onToggle={() => toggle("mood")}
/>
<ToggleRow
label={t("settings.healthRecord.labs")}
checked={sections.labs}
onToggle={() => toggle("labs")}
/>
</div>
{isPdfLike && (