feat(labs): record qualitative lab results

Add first-class qualitative lab findings (positive / negative / borderline
serology) alongside the existing numeric path. A LabResult is now EITHER a
numeric reading (value + unit, optional reference bounds) OR a qualitative one
(valueText, e.g. "negativ"), never both, never neither.

- schema: make `value` nullable, add `value_text`; migration 0181 is additive
  (DROP NOT NULL + ADD COLUMN), every existing numeric row is untouched.
- validation: numeric-XOR-qualitative refine on create + update; unit/bounds
  apply to the numeric path only; valueText capped at 120 chars. The single
  resource PUT rejects a cross-type edit.
- serialise: carry valueText through both DTOs; a qualitative row reports the
  neutral `unknown` range verdict (no number to classify).
- FHIR: a qualitative result emits valueCodeableConcept — SNOMED qualifier
  value for confidently-known terms (Negative 260385009, Positive 10828004,
  Not detected 260415000, Detected 260373001), text-only for borderline /
  grenzwertig and any unrecognised term. The raw text always rides .text.
- UI: a numeric/qualitative toggle in the lab form; list, history, detail,
  chart, sparkline, and the doctor-report PDF all render qualitative readings
  as their text and never plot or divide on a null value.
- i18n: new labs.form keys across all six locales.
- openapi: response carries a nullable value + valueText; regenerated.
This commit is contained in:
Marc-André Bombeck committed 2026-06-20 11:17:43 +02:00
1 parent e631396af3
commit a5b3715a46
30 files changed
+819 -123

No files matched your search

@@ -0,0 +1,51 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
/**
* v1.18.9 — the lab form's numeric ↔ qualitative result-type toggle.
*
* SSR-only smoke render (the suite's `renderToStaticMarkup` dialect): the form
* mounts in its default NUMERIC mode, so the test asserts the toggle is present
* (both mode labels render) and the numeric value field is shown by default.
* Interactive mode-switching is exercised by the validation + serialise unit
* tests, which pin the numeric-XOR-qualitative contract this toggle feeds.
*/
vi.mock("@/lib/api/api-fetch", () => ({
// Catalog never resolves within the synchronous SSR render.
apiGet: () => new Promise(() => {}),
apiPost: vi.fn(),
}));
import { LabForm } from "../lab-form";
function render(node: React.ReactNode) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">{node}</I18nProvider>
</QueryClientProvider>,
);
}
describe("<LabForm> result-type toggle", () => {
it("renders both numeric and qualitative mode options", () => {
const html = render(<LabForm />);
expect(html).toContain("Numeric");
expect(html).toContain("Qualitative");
expect(html).toContain("Result type");
});
it("shows the numeric value field by default (numeric mode)", () => {
const html = render(<LabForm />);
// The numeric value input carries the id `lab-value`; the qualitative input
// (`lab-valueText`) is hidden until the user switches modes.
expect(html).toContain('id="lab-value"');
expect(html).not.toContain('id="lab-valueText"');
});
});
+8 -3
View File
@@ -68,9 +68,14 @@ export function LabBiomarkerChart({
const [nowMs] = useState(() => Date.now());
const points = useMemo<ChartPoint[]>(() => {
const sorted = [...readings].sort(
(a, b) => new Date(a.takenAt).getTime() - new Date(b.takenAt).getTime(),
);
// v1.18.9 — qualitative readings (numeric value null) have nothing to plot;
// drop them so the line never renders a NaN / zero point. A series of only
// qualitative readings yields no points → the honest empty state below.
const sorted = [...readings]
.filter((r): r is LabResultDto & { value: number } => r.value !== null)
.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
+20 -11
View File
@@ -101,13 +101,15 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
);
}
const latestStatus = latest
? classifyReferenceRange(
latest.value,
marker?.lowerBound ?? null,
marker?.upperBound ?? null,
)
: "unknown";
// A qualitative latest reading (numeric value null) has no range verdict.
const latestStatus =
latest && latest.value !== null
? classifyReferenceRange(
latest.value,
marker?.lowerBound ?? null,
marker?.upperBound ?? null,
)
: "unknown";
return (
<div className="space-y-6">
@@ -147,10 +149,17 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
{latest ? (
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<span className="text-2xl font-bold tabular-nums">
{formatLabValue(latest.value)}{" "}
<span className="text-muted-foreground text-base font-normal">
{marker?.unit ?? latest.unit}
</span>
{latest.value !== null ? (
<>
{formatLabValue(latest.value)}{" "}
<span className="text-muted-foreground text-base font-normal">
{marker?.unit ?? latest.unit}
</span>
</>
) : (
// Qualitative latest reading — show the result text, no unit.
(latest.valueText ?? "")
)}
</span>
<ReferenceRangeBadge status={latestStatus} />
</div>
+95 -23
View File
@@ -93,7 +93,14 @@ export function LabForm({
const [biomarkerId, setBiomarkerId] = useState<string>(
lockedBiomarkerId ?? "",
);
// v1.18.9 — numeric vs qualitative result mode. Numeric mode enters a number
// against the marker's unit/range; qualitative mode enters a result text
// ("negativ" / "positiv" / "grenzwertig" / free text) and hides unit/range.
const [resultType, setResultType] = useState<"numeric" | "qualitative">(
"numeric",
);
const [value, setValue] = useState("");
const [valueText, setValueText] = useState("");
const [takenAt, setTakenAt] = useState(defaultTakenAtValue);
const [note, setNote] = useState("");
const [submitting, setSubmitting] = useState(false);
@@ -125,21 +132,36 @@ export function LabForm({
e.preventDefault();
setError(null);
const numericValue = parseDecimal(value);
if (!biomarkerId) {
setError(t("labs.form.pickBiomarkerError"));
return;
}
if (numericValue === null) {
setError(t("labs.form.requiredError"));
return;
const isQualitative = resultType === "qualitative";
let numericValue: number | null = null;
let qualitativeValue: string | null = null;
if (isQualitative) {
const trimmed = valueText.trim();
if (trimmed === "") {
setError(t("labs.form.requiredError"));
return;
}
qualitativeValue = trimmed;
} else {
numericValue = parseDecimal(value);
if (numericValue === null) {
setError(t("labs.form.requiredError"));
return;
}
}
setSubmitting(true);
try {
const created = await apiPost<LabResultDto>("/api/labs", {
biomarkerId,
value: numericValue,
...(isQualitative
? { valueText: qualitativeValue }
: { value: numericValue }),
takenAt: new Date(takenAt).toISOString(),
...(note.trim() ? { note: note.trim() } : {}),
});
@@ -216,25 +238,75 @@ export function LabForm({
) : null}
</div>
<div className="space-y-1.5">
<Label>{t("labs.form.resultType")}</Label>
<div className="flex gap-1">
<Button
type="button"
size="sm"
variant={resultType === "numeric" ? "secondary" : "ghost"}
className="h-8 flex-1"
onClick={() => setResultType("numeric")}
aria-pressed={resultType === "numeric"}
>
{t("labs.form.numeric")}
</Button>
<Button
type="button"
size="sm"
variant={resultType === "qualitative" ? "secondary" : "ghost"}
className="h-8 flex-1"
onClick={() => setResultType("qualitative")}
aria-pressed={resultType === "qualitative"}
>
{t("labs.form.qualitative")}
</Button>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="lab-value">
{t("labs.form.value")}
{selected ? (
<span className="text-muted-foreground font-normal">
{" "}
({selected.unit})
</span>
) : null}
</Label>
<Input
id="lab-value"
inputMode="decimal"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="0.0"
required
/>
{resultType === "numeric" ? (
<>
<Label htmlFor="lab-value">
{t("labs.form.value")}
{selected ? (
<span className="text-muted-foreground font-normal">
{" "}
({selected.unit})
</span>
) : null}
</Label>
<Input
id="lab-value"
inputMode="decimal"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="0.0"
required
/>
</>
) : (
<>
<Label htmlFor="lab-valueText">
{t("labs.form.qualitativeResult")}
</Label>
<Input
id="lab-valueText"
list="lab-qualitative-options"
value={valueText}
onChange={(e) => setValueText(e.target.value)}
placeholder={t("labs.form.qualitativePlaceholder")}
maxLength={120}
required
/>
<datalist id="lab-qualitative-options">
<option value={t("labs.form.qualNegative")} />
<option value={t("labs.form.qualPositive")} />
<option value={t("labs.form.qualBorderline")} />
</datalist>
</>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="lab-takenAt">{t("labs.form.takenAt")}</Label>
@@ -248,7 +320,7 @@ export function LabForm({
</div>
</div>
{selected && referenceText ? (
{resultType === "numeric" && selected && referenceText ? (
<p className="text-muted-foreground text-xs">
{t("labs.referenceLabel")} {referenceText} {selected.unit}
</p>
+53 -17
View File
@@ -20,7 +20,7 @@ import {
apiPut,
} from "@/lib/api/api-fetch";
import { formatDateShort } from "@/lib/format";
import { formatLabValue } from "@/lib/labs/format-value";
import { formatLabReading } from "@/lib/labs/format-value";
import { resolveNoteForUpdate } from "@/lib/labs/note-update";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -57,6 +57,10 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
const [editingId, setEditingId] = useState<string | null>(null);
const [editValue, setEditValue] = useState("");
// v1.18.9 — true when the row being edited is a qualitative reading; the
// editor then shows a text field and the PUT carries `valueText`.
const [editIsQualitative, setEditIsQualitative] = useState(false);
const [editValueText, setEditValueText] = useState("");
const [editTakenAt, setEditTakenAt] = useState("");
const [editNote, setEditNote] = useState("");
const [editLoading, setEditLoading] = useState(false);
@@ -99,14 +103,17 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
const updateMutation = useMutation({
mutationFn: (body: {
id: string;
value: number;
// Exactly one of value / valueText is set, matching the row's type.
value: number | null;
valueText: string | null;
takenAt: string;
// `undefined` → omit `note` from the PUT (leave the stored note
// untouched); `null` → clear it; a string → set it.
note: string | null | undefined;
}) =>
apiPut<LabResultDto>(`/api/labs/${body.id}`, {
value: body.value,
...(body.value !== null ? { value: body.value } : {}),
...(body.valueText !== null ? { valueText: body.valueText } : {}),
takenAt: body.takenAt,
...(body.note === undefined ? {} : { note: body.note }),
}),
@@ -122,7 +129,10 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
async function openEdit(reading: LabResultDto) {
setEditingId(reading.id);
setEditValue(String(reading.value));
const qualitative = reading.value === null;
setEditIsQualitative(qualitative);
setEditValue(reading.value !== null ? String(reading.value) : "");
setEditValueText(reading.valueText ?? "");
setEditTakenAt(toDateTimeLocal(reading.takenAt));
setEditNote("");
setEditError(null);
@@ -154,10 +164,21 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
function submitEdit(e: React.FormEvent) {
e.preventDefault();
if (!editingId) return;
const numericValue = parseDecimal(editValue);
if (numericValue === null) {
setEditError(t("labs.form.requiredError"));
return;
let numericValue: number | null = null;
let qualitativeValue: string | null = null;
if (editIsQualitative) {
const trimmed = editValueText.trim();
if (trimmed === "") {
setEditError(t("labs.form.requiredError"));
return;
}
qualitativeValue = trimmed;
} else {
numericValue = parseDecimal(editValue);
if (numericValue === null) {
setEditError(t("labs.form.requiredError"));
return;
}
}
const takenDate = new Date(editTakenAt);
if (Number.isNaN(takenDate.getTime())) {
@@ -167,6 +188,7 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
updateMutation.mutate({
id: editingId,
value: numericValue,
valueText: qualitativeValue,
takenAt: takenDate.toISOString(),
// `undefined` → omit `note` (server preserves the stored note when its
// decrypted load failed); `null` → clear; text → set.
@@ -193,7 +215,7 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
<div className="min-w-0">
<div className="flex flex-wrap items-baseline gap-x-2">
<span className="text-foreground font-semibold tabular-nums">
{formatLabValue(r.value)} {r.unit}
{formatLabReading(r)}
</span>
<ReferenceRangeBadge status={r.rangeStatus} />
</div>
@@ -236,14 +258,28 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
<form onSubmit={submitEdit} className="space-y-5">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="lab-edit-value">{t("labs.form.value")}</Label>
<Input
id="lab-edit-value"
inputMode="decimal"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
required
/>
<Label htmlFor="lab-edit-value">
{editIsQualitative
? t("labs.form.qualitativeResult")
: t("labs.form.value")}
</Label>
{editIsQualitative ? (
<Input
id="lab-edit-value"
value={editValueText}
onChange={(e) => setEditValueText(e.target.value)}
maxLength={120}
required
/>
) : (
<Input
id="lab-edit-value"
inputMode="decimal"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
required
/>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="lab-edit-takenAt">{t("labs.form.takenAt")}</Label>
+6 -5
View File
@@ -12,7 +12,7 @@ import { Skeleton } from "@/components/ui/skeleton";
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 { formatLabReading, formatLabValue } from "@/lib/labs/format-value";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { applyOrder, useModuleListPrefs } from "@/lib/module-list-prefs";
@@ -178,7 +178,7 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
</div>
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 text-xs">
<span className="text-foreground font-semibold tabular-nums">
{formatLabValue(group.latest.value)} {group.latest.unit}
{formatLabReading(group.latest)}
</span>
<span>{formatDate(group.latest.takenAt)}</span>
</div>
@@ -236,10 +236,11 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
<div className="flex items-center justify-between gap-3">
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 text-sm">
<span className="text-foreground font-semibold tabular-nums">
{formatLabValue(group.latest.value)} {group.latest.unit}
{formatLabReading(group.latest)}
</span>
{group.latest.referenceLow !== null ||
group.latest.referenceHigh !== null ? (
{group.latest.value !== null &&
(group.latest.referenceLow !== null ||
group.latest.referenceHigh !== null) ? (
<span className="text-xs">
{t("labs.referenceLabel")}{" "}
{formatReferenceRange(
+9 -6
View File
@@ -13,21 +13,24 @@
* polyline vs Recharts) is used by the doctor-report PDF. The two are not
* drift; do not unify them.
*
* 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.
* Rendered only when an analyte has ≥ 2 NUMERIC 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.
* `values` are passed oldest → newest. v1.18.9 — qualitative readings carry no
* number (`null`); they are filtered out here so a series with qualitative
* entries plots only its numeric points and never a NaN.
*/
export function LabTrendSparkline({
values,
values: rawValues,
width = 72,
height = 20,
}: {
values: number[];
values: (number | null)[];
width?: number;
height?: number;
}) {
const values = rawValues.filter((v): v is number => v !== null);
if (values.length < 2) return null;
const min = Math.min(...values);
+4 -1
View File
@@ -7,7 +7,10 @@ export interface LabResultDto {
biomarkerId: string | null;
panel: string | null;
analyte: string;
value: number;
/** Numeric reading; null for a qualitative row (see `valueText`). v1.18.9. */
value: number | null;
/** v1.18.9 — qualitative result text ("negativ" / …); null for a numeric row. */
valueText: string | null;
unit: string;
referenceLow: number | null;
referenceHigh: number | null;