mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
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:
1 parent
fbecb6b5c8
commit
72fb51c257
19 files changed
+1690
-24
No files matched your search
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2, Plus } from "lucide-react";
|
||||
|
||||
import { LabForm } from "@/components/labs/lab-form";
|
||||
import { LabList } from "@/components/labs/lab-list";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
export default function LabsPage() {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
const mounted = useMounted();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslations();
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
|
||||
const refreshVisible = useCallback(
|
||||
() => queryClient.invalidateQueries({ type: "active" }),
|
||||
[queryClient],
|
||||
);
|
||||
const pull = usePullToRefresh({
|
||||
onRefresh: refreshVisible,
|
||||
disabled: dialogOpen,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAuthenticated) {
|
||||
router.push("/auth/login");
|
||||
}
|
||||
}, [isLoading, isAuthenticated, router]);
|
||||
|
||||
if (!mounted || isLoading) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PullToRefreshIndicator {...pull} />
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{t("labs.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground truncate text-xs sm:text-sm">
|
||||
{t("labs.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setDialogOpen(true)}
|
||||
className="min-h-11 shrink-0 sm:min-h-9"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("labs.addResult")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ResponsiveSheet
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
title={t("labs.addResult")}
|
||||
description={t("labs.addDescription")}
|
||||
>
|
||||
<LabForm
|
||||
onSuccess={() => {
|
||||
setDialogOpen(false);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.labResults(),
|
||||
});
|
||||
}}
|
||||
onCancel={() => setDialogOpen(false)}
|
||||
/>
|
||||
</ResponsiveSheet>
|
||||
|
||||
<LabList onAddFirst={() => setDialogOpen(true)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -72,6 +72,7 @@ const openApiBase: Pick<
|
||||
{ name: "Export" },
|
||||
{ name: "Sync" },
|
||||
{ name: "Cycle" },
|
||||
{ name: "Labs" },
|
||||
{ name: "Admin" },
|
||||
{ name: "Meta" },
|
||||
],
|
||||
|
||||
@@ -29,6 +29,7 @@ import { cyclePaths } from "./cycle";
|
||||
import { devicePaths } from "./devices";
|
||||
import { healthRecordPaths } from "./health-record";
|
||||
import { insightsPaths } from "./insights";
|
||||
import { labsPaths } from "./labs";
|
||||
import { measurementPaths } from "./measurements";
|
||||
import { medicationPaths, medicationResource } from "./medications";
|
||||
import { metaPaths } from "./meta";
|
||||
@@ -57,6 +58,7 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
...moodPaths,
|
||||
...settingsPaths,
|
||||
...consentPaths,
|
||||
...labsPaths,
|
||||
};
|
||||
|
||||
export const openApiComponents: NonNullable<ZodOpenApiObject["components"]> = {
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* OpenAPI route table for the structured lab-result store (`/api/labs`).
|
||||
*
|
||||
* Part of the OpenAPI route table; aggregated in `./index.ts`. The request
|
||||
* bodies + query reuse the runtime Zod schemas from `@/lib/validations/labs`
|
||||
* so the wire contract stays single-source. Response shapes are declared
|
||||
* here (the route serialises a derived `rangeStatus` + `hasNote` the input
|
||||
* schema doesn't carry).
|
||||
*/
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
createLabResultSchema,
|
||||
listLabResultsSchema,
|
||||
updateLabResultSchema,
|
||||
} from "@/lib/validations/labs";
|
||||
|
||||
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
|
||||
|
||||
createLabResultSchema.meta({
|
||||
id: "CreateLabResultRequest",
|
||||
description:
|
||||
"Record a single biomarker reading (HbA1c, LDL, ferritin, TSH, …). `analyte` + `unit` are free-form (a lab prints its own naming). Reference bounds are independently optional; when both are present `referenceLow` must not exceed `referenceHigh`. `takenAt` is a backdatable ISO instant (no future, ≤ 50 years past). The optional `note` is encrypted at rest.",
|
||||
});
|
||||
|
||||
updateLabResultSchema.meta({
|
||||
id: "UpdateLabResultRequest",
|
||||
description:
|
||||
"Partial edit of a lab result. An omitted key leaves the column untouched; an explicit `null` on `panel` / `note` / a reference bound clears it.",
|
||||
});
|
||||
|
||||
listLabResultsSchema.meta({
|
||||
id: "ListLabResultsQuery",
|
||||
description:
|
||||
"Query params for the lab-result list: optional `analyte` (exact) + `panel` filters, an inclusive `from`/`to` date range, and `limit` (≤ 500) / `offset` pagination. Defaults to `takenAt` DESC.",
|
||||
});
|
||||
|
||||
const rangeStatusEnum = z
|
||||
.enum(["in-range", "below", "above", "unknown"])
|
||||
.meta({
|
||||
id: "LabReferenceRangeStatus",
|
||||
description:
|
||||
"Server-computed, NEUTRAL reference-range verdict. `unknown` when the lab reported no usable bounds. Inclusive bounds: a value on the limit reads in-range. The badge that renders this must stay calm and informative — not an alarming red.",
|
||||
});
|
||||
|
||||
const labResultRow = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
panel: z.string().nullable(),
|
||||
analyte: z.string(),
|
||||
value: z.number(),
|
||||
unit: z.string(),
|
||||
referenceLow: z.number().nullable(),
|
||||
referenceHigh: z.number().nullable(),
|
||||
takenAt: z.string(),
|
||||
source: z.string(),
|
||||
hasNote: z.boolean(),
|
||||
rangeStatus: rangeStatusEnum,
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
})
|
||||
.meta({
|
||||
id: "LabResult",
|
||||
description:
|
||||
"A stored lab result. The encrypted note is never echoed in list rows; `hasNote` flags its presence and the single-resource GET returns the decrypted `note`.",
|
||||
});
|
||||
|
||||
const labResultDetail = labResultRow
|
||||
.omit({ hasNote: true })
|
||||
.extend({ note: z.string().nullable() })
|
||||
.meta({
|
||||
id: "LabResultDetail",
|
||||
description:
|
||||
"Single lab result including its decrypted free-text `note` (or null).",
|
||||
});
|
||||
|
||||
const listResponse = z
|
||||
.object({
|
||||
results: z.array(labResultRow),
|
||||
meta: z.object({
|
||||
total: z.number(),
|
||||
limit: z.number(),
|
||||
offset: z.number(),
|
||||
}),
|
||||
})
|
||||
.meta({ id: "ListLabResultsResponse" });
|
||||
|
||||
const notFound = {
|
||||
"404": {
|
||||
description: "Lab result not found (or owned by another user).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
};
|
||||
|
||||
export const labsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/labs": {
|
||||
get: {
|
||||
tags: ["Labs"],
|
||||
summary: "List the caller's lab results",
|
||||
description:
|
||||
"Returns the caller's live (non-deleted) lab results, newest first, with optional analyte / panel / date-range filters. Each row carries a server-computed neutral `rangeStatus` and a `hasNote` flag (the encrypted note itself is not echoed).",
|
||||
requestParams: { query: listLabResultsSchema },
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Lab-result list.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(listResponse, "ListLabResultsEnvelope"),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
post: {
|
||||
tags: ["Labs"],
|
||||
summary: "Record a lab result",
|
||||
description:
|
||||
"Creates a single lab result for the caller. `source` is hardcoded MANUAL on this path. The optional note is AES-256-GCM encrypted before write. Audits as `labResult.create`.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: createLabResultSchema } },
|
||||
},
|
||||
responses: {
|
||||
"201": {
|
||||
description: "Created lab result.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(labResultRow, "CreateLabResultResponse"),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/labs/{id}": {
|
||||
get: {
|
||||
tags: ["Labs"],
|
||||
summary: "Fetch a single lab result",
|
||||
description:
|
||||
"Returns the lab result including its decrypted `note`. Cross-user rows surface as 404.",
|
||||
requestParams: { path: z.object({ id: z.string() }) },
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Lab-result detail.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(labResultDetail, "GetLabResultResponse"),
|
||||
},
|
||||
},
|
||||
},
|
||||
...notFound,
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
put: {
|
||||
tags: ["Labs"],
|
||||
summary: "Edit a lab result",
|
||||
description:
|
||||
"Partial edit; omitted fields are untouched, an explicit null clears `panel` / `note` / a reference bound. Audits as `labResult.update`.",
|
||||
requestParams: { path: z.object({ id: z.string() }) },
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: updateLabResultSchema } },
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Updated lab result.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(labResultRow, "UpdateLabResultResponse"),
|
||||
},
|
||||
},
|
||||
},
|
||||
...notFound,
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
tags: ["Labs"],
|
||||
summary: "Delete a lab result",
|
||||
description:
|
||||
"Soft-deletes the lab result (stamps `deletedAt`). Idempotent. Audits as `labResult.delete`.",
|
||||
requestParams: { path: z.object({ id: z.string() }) },
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Deletion succeeded.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({ deleted: z.boolean() }),
|
||||
"DeleteLabResultResponse",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...notFound,
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user