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

+30 -7
View File
@@ -6081,6 +6081,10 @@ paths:
maxLength: 120
value:
type: number
valueText:
type: string
minLength: 1
maxLength: 120
unit:
type: string
minLength: 1
@@ -6100,7 +6104,6 @@ paths:
- type: string
const: ""
required:
- value
- takenAt
responses:
"201":
@@ -6174,6 +6177,10 @@ paths:
maxLength: 120
value:
type: number
valueText:
type: string
minLength: 1
maxLength: 120
unit:
type: string
minLength: 1
@@ -18838,7 +18845,13 @@ components:
analyte:
type: string
value:
type: number
anyOf:
- type: number
- type: "null"
valueText:
anyOf:
- type: string
- type: "null"
unit:
type: string
referenceLow:
@@ -18867,6 +18880,7 @@ components:
- panel
- analyte
- value
- valueText
- unit
- referenceLow
- referenceHigh
@@ -18877,10 +18891,12 @@ components:
- createdAt
- updatedAt
additionalProperties: false
description: A stored lab result. `biomarkerId` links the user-scoped catalog marker (null for legacy free-text rows);
when linked, `analyte` / `unit` / `referenceLow` / `referenceHigh` are RESOLVED server-side from the Biomarker —
the client renders them and never recomputes the range. The encrypted note is never echoed in list rows;
`hasNote` flags its presence and the single-resource GET returns the decrypted `note`.
description: A stored lab result. Exactly one of `value` (numeric) or `valueText` (qualitative, e.g. "negativ") is
non-null; `rangeStatus` is always `unknown` for a qualitative row (no number to classify). `biomarkerId` links
the user-scoped catalog marker (null for legacy free-text rows); when linked, `analyte` / `unit` /
`referenceLow` / `referenceHigh` are RESOLVED server-side from the Biomarker — the client renders them and never
recomputes the range. The encrypted note is never echoed in list rows; `hasNote` flags its presence and the
single-resource GET returns the decrypted `note`.
LabReferenceRangeStatus:
type: string
enum:
@@ -18941,7 +18957,13 @@ components:
analyte:
type: string
value:
type: number
anyOf:
- type: number
- type: "null"
valueText:
anyOf:
- type: string
- type: "null"
unit:
type: string
referenceLow:
@@ -18972,6 +18994,7 @@ components:
- panel
- analyte
- value
- valueText
- unit
- referenceLow
- referenceHigh
+9 -1
View File
@@ -6513,7 +6513,15 @@
"biomarkerPlaceholder": "Biomarker auswählen",
"defineNew": "+ Neuen Biomarker anlegen",
"noBiomarkersHint": "Lege zuerst einen Biomarker an, um einen Wert zu erfassen.",
"pickBiomarkerError": "Bitte zuerst einen Biomarker auswählen."
"pickBiomarkerError": "Bitte zuerst einen Biomarker auswählen.",
"resultType": "Ergebnistyp",
"numeric": "Numerisch",
"qualitative": "Qualitativ",
"qualitativeResult": "Ergebnis",
"qualitativePlaceholder": "z. B. negativ",
"qualNegative": "negativ",
"qualPositive": "positiv",
"qualBorderline": "grenzwertig"
},
"backToLabs": "Zurück zu den Laborwerten",
"hasNote": "Notiz",
+9 -1
View File
@@ -6513,7 +6513,15 @@
"biomarkerPlaceholder": "Pick a biomarker",
"defineNew": "+ Define new biomarker",
"noBiomarkersHint": "Define a biomarker first to record a value against it.",
"pickBiomarkerError": "Pick a biomarker first."
"pickBiomarkerError": "Pick a biomarker first.",
"resultType": "Result type",
"numeric": "Numeric",
"qualitative": "Qualitative",
"qualitativeResult": "Result",
"qualitativePlaceholder": "e.g. negative",
"qualNegative": "negative",
"qualPositive": "positive",
"qualBorderline": "borderline"
},
"backToLabs": "Back to lab results",
"hasNote": "Note",
+9 -1
View File
@@ -6513,7 +6513,15 @@
"biomarkerPlaceholder": "Elige un biomarcador",
"defineNew": "+ Definir nuevo biomarcador",
"noBiomarkersHint": "Define primero un biomarcador para registrar un valor.",
"pickBiomarkerError": "Elige primero un biomarcador."
"pickBiomarkerError": "Elige primero un biomarcador.",
"resultType": "Tipo de resultado",
"numeric": "Numérico",
"qualitative": "Cualitativo",
"qualitativeResult": "Resultado",
"qualitativePlaceholder": "p. ej. negativo",
"qualNegative": "negativo",
"qualPositive": "positivo",
"qualBorderline": "límite"
},
"backToLabs": "Volver a los resultados",
"hasNote": "Nota",
+9 -1
View File
@@ -6513,7 +6513,15 @@
"biomarkerPlaceholder": "Choisir un biomarqueur",
"defineNew": "+ Définir un nouveau biomarqueur",
"noBiomarkersHint": "Définissez d'abord un biomarqueur pour enregistrer une valeur.",
"pickBiomarkerError": "Choisissez d'abord un biomarqueur."
"pickBiomarkerError": "Choisissez d'abord un biomarqueur.",
"resultType": "Type de résultat",
"numeric": "Numérique",
"qualitative": "Qualitatif",
"qualitativeResult": "Résultat",
"qualitativePlaceholder": "p. ex. négatif",
"qualNegative": "négatif",
"qualPositive": "positif",
"qualBorderline": "limite"
},
"backToLabs": "Retour aux résultats",
"hasNote": "Note",
+9 -1
View File
@@ -6513,7 +6513,15 @@
"biomarkerPlaceholder": "Scegli un biomarcatore",
"defineNew": "+ Definisci nuovo biomarcatore",
"noBiomarkersHint": "Definisci prima un biomarcatore per registrare un valore.",
"pickBiomarkerError": "Scegli prima un biomarcatore."
"pickBiomarkerError": "Scegli prima un biomarcatore.",
"resultType": "Tipo di risultato",
"numeric": "Numerico",
"qualitative": "Qualitativo",
"qualitativeResult": "Risultato",
"qualitativePlaceholder": "es. negativo",
"qualNegative": "negativo",
"qualPositive": "positivo",
"qualBorderline": "borderline"
},
"backToLabs": "Torna ai risultati",
"hasNote": "Nota",
+9 -1
View File
@@ -6513,7 +6513,15 @@
"biomarkerPlaceholder": "Wybierz biomarker",
"defineNew": "+ Zdefiniuj nowy biomarker",
"noBiomarkersHint": "Najpierw zdefiniuj biomarker, aby zapisać wartość.",
"pickBiomarkerError": "Najpierw wybierz biomarker."
"pickBiomarkerError": "Najpierw wybierz biomarker.",
"resultType": "Typ wyniku",
"numeric": "Liczbowy",
"qualitative": "Jakościowy",
"qualitativeResult": "Wynik",
"qualitativePlaceholder": "np. ujemny",
"qualNegative": "ujemny",
"qualPositive": "dodatni",
"qualBorderline": "graniczny"
},
"backToLabs": "Wróć do wyników",
"hasNote": "Notatka",
@@ -0,0 +1,14 @@
-- v1.18.9 — qualitative lab results (positive/negative/borderline serology).
--
-- A LabResult was numeric-only (`value` NOT NULL). Qualitative findings
-- ("Hepatitis Bs-Antigen: negativ") have no number to store. This migration is
-- ADDITIVE and preserves every existing numeric row:
--
-- 1. `value` becomes nullable so a qualitative row can omit it. Existing rows
-- keep their numeric value untouched (DROP NOT NULL never rewrites data).
-- 2. `value_text` is added (nullable) to hold the qualitative result string.
--
-- A row is QUANTITATIVE when `value IS NOT NULL`, QUALITATIVE when
-- `value_text IS NOT NULL`. The application layer enforces the exclusive-or.
ALTER TABLE "lab_results" ALTER COLUMN "value" DROP NOT NULL;
ALTER TABLE "lab_results" ADD COLUMN "value_text" text;
+8 -1
View File
@@ -3391,7 +3391,14 @@ model LabResult {
panel String?
/// The biomarker name, e.g. "LDL", "HbA1c", "Ferritin".
analyte String
value Float
/// Numeric reading. NULL for a qualitative result (see `valueText`). Exactly
/// one of `value` / `valueText` is set per row: `value IS NOT NULL` marks a
/// quantitative reading, `valueText IS NOT NULL` a qualitative one.
value Float?
/// v1.18.9 — qualitative result as free text, e.g. "negativ" / "positiv" /
/// "grenzwertig" / "nicht nachweisbar". NULL for a numeric reading. The
/// reference bounds + range verdict are meaningless for a qualitative row.
valueText String? @map("value_text")
unit String
/// Optional reference-range bounds as reported by the lab.
referenceLow Float? @map("reference_low")
+22
View File
@@ -150,6 +150,24 @@ export const PUT = apiHandler(
);
}
// v1.18.9 — a row keeps its type. Editing `value` on a qualitative row or
// `valueText` on a numeric row would either leave both columns populated or
// strand the row in an ambiguous state. Reject the cross-type edit with a
// 422 — switch types by deleting and re-adding.
const rowIsQualitative = existing.valueText !== null;
if (rowIsQualitative && d.value !== undefined) {
return apiError(
"This is a qualitative reading — edit its result text, not a numeric value",
422,
);
}
if (!rowIsQualitative && d.valueText !== undefined) {
return apiError(
"This is a numeric reading — edit its value, not a qualitative result",
422,
);
}
// Build `data` field-by-field. `undefined` → leave the column untouched;
// an explicit `null` on `panel` / `note` / a reference bound clears it.
// A reading linked to a catalog marker is edited via VALUE / takenAt /
@@ -158,6 +176,10 @@ export const PUT = apiHandler(
if (d.panel !== undefined) data.panel = d.panel;
if (d.analyte !== undefined) data.analyte = d.analyte;
if (d.value !== undefined) data.value = d.value;
// v1.18.9 — qualitative edit. A row stays single-typed: editing `value` on a
// qualitative row (or `valueText` on a numeric row) crosses the row's type
// and is rejected below. An edit of the matching field updates in place.
if (d.valueText !== undefined) data.valueText = d.valueText;
if (d.unit !== undefined) data.unit = d.unit;
if (d.referenceLow !== undefined) data.referenceLow = d.referenceLow;
if (d.referenceHigh !== undefined) data.referenceHigh = d.referenceHigh;
+14 -4
View File
@@ -173,6 +173,7 @@ async function postLabResult(request: NextRequest) {
panel,
analyte,
value,
valueText,
unit,
referenceLow,
referenceHigh,
@@ -180,6 +181,10 @@ async function postLabResult(request: NextRequest) {
note,
} = parsed.data;
// v1.18.9 — a qualitative reading carries `valueText` ("negativ" / …) instead
// of a number. The Zod refine guarantees exactly one of the two is present.
const isQualitative = valueText !== undefined;
// Every row links a catalog marker — no `LabResult` ever persists unlinked
// (v1.18.1 High). Two paths converge on a `biomarker`:
// - Structured: a `biomarkerId` is supplied; resolve + verify ownership
@@ -207,9 +212,12 @@ async function postLabResult(request: NextRequest) {
} else {
biomarker = await resolveOrMintBiomarker(user.id, {
analyte: analyte as string,
unit: unit as string,
referenceLow: referenceLow ?? null,
referenceHigh: referenceHigh ?? null,
// A qualitative reading has no numeric unit / range — mint the catalog
// marker with an empty unit label and no bounds. A numeric reading
// supplies its unit (Zod requires it on the free-text numeric path).
unit: isQualitative ? (unit ?? "") : (unit as string),
referenceLow: isQualitative ? null : (referenceLow ?? null),
referenceHigh: isQualitative ? null : (referenceHigh ?? null),
panel: panel ?? null,
});
}
@@ -224,7 +232,9 @@ async function postLabResult(request: NextRequest) {
biomarkerId: biomarker.id,
panel: biomarker.panel,
analyte: biomarker.name,
value,
// Exactly one of value / valueText is set (the Zod XOR refine).
value: value ?? null,
valueText: valueText ?? null,
unit: biomarker.unit,
referenceLow: biomarker.lowerBound,
referenceHigh: biomarker.upperBound,
@@ -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;
+12 -2
View File
@@ -277,8 +277,16 @@ export interface DoctorReportData {
panel: string | null;
/** Biomarker name as the user recorded it. */
analyte: string;
/** Latest recorded value for this analyte over the window. */
value: number;
/**
* Latest numeric value for this analyte over the window; null for a
* qualitative reading (see `valueText`). v1.18.9.
*/
value: number | null;
/**
* v1.18.9 — qualitative result text ("negativ" / "positiv" / …); null for a
* numeric reading. Exactly one of `value` / `valueText` is set.
*/
valueText: string | null;
unit: string;
referenceLow: number | null;
referenceHigh: number | null;
@@ -1329,6 +1337,7 @@ export async function collectDoctorReportData(
panel: true,
analyte: true,
value: true,
valueText: true,
unit: true,
referenceLow: true,
referenceHigh: true,
@@ -1348,6 +1357,7 @@ export async function collectDoctorReportData(
panel: r.panel,
analyte: r.analyte,
value: r.value,
valueText: r.valueText,
unit: r.unit,
referenceLow: r.referenceLow,
referenceHigh: r.referenceHigh,
+16 -7
View File
@@ -1489,13 +1489,22 @@ export function buildDoctorReportPdfDocument(
}
};
const labRows = data.labResults.map((lr) => [
lr.panel ? `${lr.analyte} (${lr.panel})` : lr.analyte,
`${num(lr.value)} ${lr.unit}`.trim(),
rangeText(lr.referenceLow, lr.referenceHigh),
statusGlyph(lr.value, lr.referenceLow, lr.referenceHigh),
fmtDate(lr.takenAt),
]);
const labRows = data.labResults.map((lr) => {
// v1.18.9 — a qualitative reading (`value === null`) prints its result
// text in the value column and has no numeric range / status glyph.
const isQualitative = lr.value === null;
return [
lr.panel ? `${lr.analyte} (${lr.panel})` : lr.analyte,
isQualitative
? (lr.valueText ?? "")
: `${num(lr.value as number)} ${lr.unit}`.trim(),
isQualitative ? "—" : rangeText(lr.referenceLow, lr.referenceHigh),
isQualitative
? ""
: statusGlyph(lr.value as number, lr.referenceLow, lr.referenceHigh),
fmtDate(lr.takenAt),
];
});
autoTable(doc, {
startY: y,
@@ -50,6 +50,7 @@ describe("lab-result FHIR Observations", () => {
panel: "Lipid panel",
analyte: "LDL",
value: 110,
valueText: null,
unit: "mg/dL",
referenceLow: null,
referenceHigh: 116,
@@ -60,6 +61,7 @@ describe("lab-result FHIR Observations", () => {
panel: null,
analyte: "HbA1c",
value: 5.4,
valueText: null,
unit: "%",
referenceLow: 4,
referenceHigh: 5.6,
@@ -131,6 +133,7 @@ describe("lab-result FHIR Observations", () => {
panel: null,
analyte: "CRP",
value: 2.1,
valueText: null,
unit: "mg/L",
referenceLow: null,
referenceHigh: null,
@@ -171,6 +174,7 @@ describe("lab-result LOINC + canonical UCUM coding (v1.18.8)", () => {
panel: null,
analyte: type,
value,
valueText: null,
unit,
referenceLow: null,
referenceHigh: null,
@@ -246,3 +250,66 @@ describe("lab-result LOINC + canonical UCUM coding (v1.18.8)", () => {
expect(o?.valueQuantity?.system).toBe("http://unitsofmeasure.org");
});
});
describe("qualitative lab-result FHIR Observations (v1.18.9)", () => {
function qualOf(analyte: string, valueText: string) {
const bundle = buildFhirDocumentBundle(
makeData({
labResults: [
{
panel: null,
analyte,
value: null,
valueText,
unit: "",
referenceLow: null,
referenceHigh: null,
takenAt: "2026-04-20T08:00:00.000Z",
count: 1,
},
],
}),
{ insuranceNumber: "A123456780" },
FIXED_NOW,
);
return observationsOf(bundle).find((o) => o.code.text === analyte);
}
it("emits valueCodeableConcept (not valueQuantity) for a negative result", () => {
const o = qualOf("Hepatitis Bs-Antigen", "negativ");
expect(o?.valueQuantity).toBeUndefined();
expect(o?.valueCodeableConcept?.coding?.[0].system).toBe(
"http://snomed.info/sct",
);
expect(o?.valueCodeableConcept?.coding?.[0].code).toBe("260385009");
expect(o?.valueCodeableConcept?.coding?.[0].display).toBe("Negative");
// The raw recorded text always rides .text.
expect(o?.valueCodeableConcept?.text).toBe("negativ");
// No numeric reference range on a qualitative observation.
expect(o?.referenceRange).toBeUndefined();
});
it("maps a positive (English) result to the Positive SNOMED concept", () => {
const o = qualOf("HIV Ab", "positive");
expect(o?.valueCodeableConcept?.coding?.[0].code).toBe("10828004");
expect(o?.valueCodeableConcept?.text).toBe("positive");
});
it("maps 'nicht nachweisbar' to the Not detected SNOMED concept", () => {
const o = qualOf("HCV RNA", "nicht nachweisbar");
expect(o?.valueCodeableConcept?.coding?.[0].code).toBe("260415000");
});
it("keeps borderline / grenzwertig text-only (no fabricated code)", () => {
const o = qualOf("Some Serology", "grenzwertig");
expect(o?.valueCodeableConcept?.coding).toBeUndefined();
expect(o?.valueCodeableConcept?.text).toBe("grenzwertig");
});
it("keeps an unrecognised qualitative term text-only", () => {
const o = qualOf("Some Serology", "schwach reaktiv");
expect(o?.valueCodeableConcept?.coding).toBeUndefined();
expect(o?.valueCodeableConcept?.text).toBe("schwach reaktiv");
expect(o?.valueQuantity).toBeUndefined();
});
});
+86
View File
@@ -0,0 +1,86 @@
/**
* v1.18.9 — qualitative lab result → SNOMED CT concept (or honest text-only).
*
* A qualitative serology result ("negativ" / "positiv" / "nicht nachweisbar")
* has no number, so its FHIR Observation carries a `valueCodeableConcept`
* instead of a `valueQuantity`. This module maps the common German / English
* result terms onto a small set of WELL-ESTABLISHED SNOMED CT qualifier-value
* concepts. The raw recorded text ALWAYS rides `.text`; a coded `coding` is
* added only when the term resolves CONFIDENTLY. When in doubt the result stays
* text-only — never a fabricated code — mirroring the conservative stance of
* the `lab-loinc` and `illness-snomed` mappers.
*
* Concepts (SNOMED CT International, qualifier value, REFERENCED not
* redistributed per the licence):
* - 260385009 "Negative" — negativ / negative
* - 10828004 "Positive" — positiv / positive
* - 260415000 "Not detected" — nicht nachweisbar / not detected
* - 260373001 "Detected" — nachweisbar / detected
*
* Borderline / grenzwertig is DELIBERATELY left text-only: the candidate
* "Borderline" qualifier concept could not be confidently verified, and an
* unverified code is worse than an honest `.text`. Any unrecognised term
* likewise stays text-only.
*
* Systems: SNOMED CT — http://snomed.info/sct
*/
import type { FhirCodeableConcept } from "@/lib/fhir/types";
/**
* SNOMED CT system URI. Inlined (rather than imported from `resources.ts`) to
* keep this leaf module free of the heavy `resources.ts` import graph and avoid
* an import cycle — `resources.ts` consumes THIS module.
*/
const SNOMED_SYSTEM = "http://snomed.info/sct";
interface QualitativeConcept {
code: string;
display: string;
}
/**
* Normalised result term → SNOMED qualifier-value concept. The left side is the
* lower-cased, whitespace-collapsed recorded text. Only confidently-known terms
* appear; everything else falls through to text-only.
*/
const QUALITATIVE_SNOMED: Record<string, QualitativeConcept> = {
// Negative-like
negativ: { code: "260385009", display: "Negative" },
negative: { code: "260385009", display: "Negative" },
neg: { code: "260385009", display: "Negative" },
"nicht nachweisbar": { code: "260415000", display: "Not detected" },
"not detected": { code: "260415000", display: "Not detected" },
// Positive-like
positiv: { code: "10828004", display: "Positive" },
positive: { code: "10828004", display: "Positive" },
pos: { code: "10828004", display: "Positive" },
nachweisbar: { code: "260373001", display: "Detected" },
detected: { code: "260373001", display: "Detected" },
};
/** Collapse a recorded qualitative term to a lookup key. */
function normaliseQualitativeKey(text: string): string {
return text.trim().toLowerCase().replace(/\s+/g, " ");
}
/**
* Build the `valueCodeableConcept` for a qualitative lab result. The raw text
* always rides `.text`; a SNOMED `coding` is added only for a confidently-
* recognised term. Borderline / grenzwertig and any unknown term stay
* text-only.
*/
export function qualitativeValueConcept(
valueText: string,
): FhirCodeableConcept {
const concept = QUALITATIVE_SNOMED[normaliseQualitativeKey(valueText)];
if (!concept) {
return { text: valueText };
}
return {
coding: [
{ system: SNOMED_SYSTEM, code: concept.code, display: concept.display },
],
text: valueText,
};
}
+39 -17
View File
@@ -45,6 +45,7 @@ import {
} from "@/lib/fhir/loinc-map";
import { ILLNESS_TYPE_SNOMED } from "@/lib/fhir/illness-snomed";
import { resolveLabCoding } from "@/lib/fhir/lab-loinc";
import { qualitativeValueConcept } from "@/lib/fhir/lab-qualitative";
import type {
FhirCodeableConcept,
FhirObservation,
@@ -640,6 +641,43 @@ export function observationsFromReportData(
// lab's reference bounds map onto R4 `referenceRange`.
for (const lab of data.labResults ?? []) {
obsSeq += 1;
// Forward-compat: when the v1.19.0 Biomarker catalog lands and the DTO
// carries a resolved canonical analyte name, thread it as the third arg so
// the catalog name wins over the free-text `analyte`. Absent today.
const coding = resolveLabCoding(lab.analyte, lab.unit);
const text = lab.panel ? `${lab.analyte} (${lab.panel})` : lab.analyte;
const code = coding
? {
coding: [
{
system: LOINC_SYSTEM,
code: coding.loinc,
display: coding.display,
},
],
text,
}
: { text };
// v1.18.9 — a qualitative result (`value === null`) emits a
// `valueCodeableConcept` (SNOMED qualifier value when confidently known,
// else text-only) and NO `valueQuantity` / `referenceRange` — bounds are
// meaningless for it. A numeric result keeps the established
// valueQuantity + LOINC/UCUM path unchanged.
if (lab.value === null) {
push({
resourceType: "Observation",
id: `obs-${obsSeq}`,
status: "final",
category: [categoryConcept("laboratory")],
code,
subject: patientRef,
effectiveDateTime: lab.takenAt,
valueCodeableConcept: qualitativeValueConcept(lab.valueText ?? ""),
});
continue;
}
const referenceRange =
lab.referenceLow !== null || lab.referenceHigh !== null
? [
@@ -653,28 +691,12 @@ export function observationsFromReportData(
},
]
: undefined;
// Forward-compat: when the v1.19.0 Biomarker catalog lands and the DTO
// carries a resolved canonical analyte name, thread it as the third arg so
// the catalog name wins over the free-text `analyte`. Absent today.
const coding = resolveLabCoding(lab.analyte, lab.unit);
const text = lab.panel ? `${lab.analyte} (${lab.panel})` : lab.analyte;
push({
resourceType: "Observation",
id: `obs-${obsSeq}`,
status: "final",
category: [categoryConcept("laboratory")],
code: coding
? {
coding: [
{
system: LOINC_SYSTEM,
code: coding.loinc,
display: coding.display,
},
],
text,
}
: { text },
code,
subject: patientRef,
effectiveDateTime: lab.takenAt,
valueQuantity: {
+42
View File
@@ -13,6 +13,7 @@ const baseRow: LabRow = {
panel: "Legacy panel",
analyte: "ldl", // lowercase legacy spelling
value: 130,
valueText: null,
unit: "mg/dl", // lowercase legacy unit
referenceLow: null,
referenceHigh: 100, // stale per-row bound
@@ -94,3 +95,44 @@ describe("serialiseLabResultDetail", () => {
expect(dto.unit).toBe("mg/dL");
});
});
describe("qualitative readings (v1.18.9)", () => {
const qualRow: LabRow = {
...baseRow,
id: "lr_q",
analyte: "Hepatitis Bs-Antigen",
value: null,
valueText: "negativ",
unit: "",
referenceLow: null,
referenceHigh: null,
biomarkerId: null,
};
it("serialises valueText and a null numeric value", () => {
const dto = serialiseLabResult(qualRow, null);
expect(dto.value).toBeNull();
expect(dto.valueText).toBe("negativ");
});
it("reports an 'unknown' range verdict for a qualitative row (no comparison)", () => {
// Even if the linked marker carried numeric bounds, a null value cannot be
// compared — the verdict must stay the neutral 'unknown'.
const dto = serialiseLabResult(
{ ...qualRow, biomarkerId: "bm_1" },
{
...biomarker,
lowerBound: 0,
upperBound: 1,
},
);
expect(dto.rangeStatus).toBe("unknown");
});
it("carries valueText through the detail serialiser", () => {
const dto = serialiseLabResultDetail(qualRow, null, null);
expect(dto.value).toBeNull();
expect(dto.valueText).toBe("negativ");
expect(dto.rangeStatus).toBe("unknown");
});
});
+17
View File
@@ -11,3 +11,20 @@ export function formatLabValue(value: number): string {
? String(value)
: String(Number(value.toFixed(2)));
}
/**
* v1.18.9 — render a reading's display value: a qualitative row (numeric
* `value` is null) shows its `valueText` verbatim; a numeric row shows the
* formatted number plus its unit. One helper so the list card, the history
* rows, and the detail header stay consistent.
*/
export function formatLabReading(reading: {
value: number | null;
valueText: string | null;
unit: string;
}): string {
if (reading.value === null) {
return reading.valueText ?? "";
}
return `${formatLabValue(reading.value)} ${reading.unit}`.trim();
}
+23 -3
View File
@@ -33,7 +33,10 @@ export interface LabRow {
id: string;
panel: string | null;
analyte: string;
value: number;
/** Numeric reading; null for a qualitative row (see `valueText`). */
value: number | null;
/** v1.18.9 — qualitative result text; null for a numeric row. */
valueText: string | null;
unit: string;
referenceLow: number | null;
referenceHigh: number | null;
@@ -86,6 +89,21 @@ export function resolveLabFields(
};
}
/**
* v1.18.9 — the reference-range verdict for a row. A qualitative row (no
* numeric `value`) has nothing to compare against the bounds, so it always
* reports `"unknown"` — the neutral, no-verdict state — never a fabricated
* in/out classification. A numeric row classifies against the resolved bounds.
*/
function rowRangeStatus(
value: number | null,
referenceLow: number | null,
referenceHigh: number | null,
) {
if (value === null) return "unknown" as const;
return classifyReferenceRange(value, referenceLow, referenceHigh);
}
/**
* Serialise a lab row to the list DTO — never echoes the encrypted note bytes
* (only the `hasNote` flag) and resolves name/unit/range server-side.
@@ -101,13 +119,14 @@ export function serialiseLabResult(
panel: resolved.panel,
analyte: resolved.analyte,
value: row.value,
valueText: row.valueText,
unit: resolved.unit,
referenceLow: resolved.referenceLow,
referenceHigh: resolved.referenceHigh,
takenAt: row.takenAt.toISOString(),
source: row.source,
hasNote: row.noteEncrypted !== null,
rangeStatus: classifyReferenceRange(
rangeStatus: rowRangeStatus(
row.value,
resolved.referenceLow,
resolved.referenceHigh,
@@ -133,13 +152,14 @@ export function serialiseLabResultDetail(
panel: resolved.panel,
analyte: resolved.analyte,
value: row.value,
valueText: row.valueText,
unit: resolved.unit,
referenceLow: resolved.referenceLow,
referenceHigh: resolved.referenceHigh,
takenAt: row.takenAt.toISOString(),
source: row.source,
note,
rangeStatus: classifyReferenceRange(
rangeStatus: rowRangeStatus(
row.value,
resolved.referenceLow,
resolved.referenceHigh,
+7 -4
View File
@@ -21,13 +21,13 @@ 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.",
'Record a single biomarker reading (HbA1c, LDL, ferritin, TSH, …). A reading is EITHER numeric (`value` + `unit`, optional reference bounds) OR qualitative (`valueText`, e.g. "negativ" / "positiv") — exactly one, never both, never neither. `analyte` + `unit` are free-form (a lab prints its own naming); a qualitative reading needs no unit / bounds. When both numeric bounds 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.",
"Partial edit of a lab result. An omitted key leaves the column untouched; an explicit `null` on `panel` / `note` / a reference bound clears it. Edit the matching value field for the row's type — `value` for a numeric row, `valueText` for a qualitative one; a request must not carry both, and a cross-type edit (the wrong field for the row) is rejected.",
});
listLabResultsSchema.meta({
@@ -48,7 +48,10 @@ const labResultRow = z
biomarkerId: z.string().nullable(),
panel: z.string().nullable(),
analyte: z.string(),
value: z.number(),
// v1.18.9 — `value` is null for a qualitative reading (`valueText` set);
// exactly one of the two is non-null per row.
value: z.number().nullable(),
valueText: z.string().nullable(),
unit: z.string(),
referenceLow: z.number().nullable(),
referenceHigh: z.number().nullable(),
@@ -62,7 +65,7 @@ const labResultRow = z
.meta({
id: "LabResult",
description:
"A stored lab result. `biomarkerId` links the user-scoped catalog marker (null for legacy free-text rows); when linked, `analyte` / `unit` / `referenceLow` / `referenceHigh` are RESOLVED server-side from the Biomarker — the client renders them and never recomputes the range. The encrypted note is never echoed in list rows; `hasNote` flags its presence and the single-resource GET returns the decrypted `note`.",
'A stored lab result. Exactly one of `value` (numeric) or `valueText` (qualitative, e.g. "negativ") is non-null; `rangeStatus` is always `unknown` for a qualitative row (no number to classify). `biomarkerId` links the user-scoped catalog marker (null for legacy free-text rows); when linked, `analyte` / `unit` / `referenceLow` / `referenceHigh` are RESOLVED server-side from the Biomarker — the client renders them and never recomputes the range. 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
@@ -122,6 +122,72 @@ describe("createLabResultSchema", () => {
});
expect(r.success).toBe(false);
});
// ── qualitative readings (v1.18.9) ────────────────────────────────
it("accepts a qualitative reading (valueText, no numeric value/unit)", () => {
const r = createLabResultSchema.safeParse({
analyte: "Hepatitis Bs-Antigen",
valueText: "negativ",
takenAt: RECENT,
});
expect(r.success).toBe(true);
});
it("accepts a qualitative reading against a catalog biomarker", () => {
const r = createLabResultSchema.safeParse({
biomarkerId: "bm_123",
valueText: "positiv",
takenAt: RECENT,
});
expect(r.success).toBe(true);
});
it("rejects a reading carrying BOTH value and valueText", () => {
const r = createLabResultSchema.safeParse({
analyte: "LDL",
value: 110,
unit: "mg/dL",
valueText: "negativ",
takenAt: RECENT,
});
expect(r.success).toBe(false);
});
it("rejects a reading carrying NEITHER value nor valueText", () => {
const r = createLabResultSchema.safeParse({
analyte: "LDL",
unit: "mg/dL",
takenAt: RECENT,
});
expect(r.success).toBe(false);
});
it("rejects an empty qualitative valueText", () => {
const r = createLabResultSchema.safeParse({
analyte: "Serology",
valueText: " ",
takenAt: RECENT,
});
expect(r.success).toBe(false);
});
it("rejects an over-long valueText (> 120 chars)", () => {
const r = createLabResultSchema.safeParse({
analyte: "Serology",
valueText: "x".repeat(121),
takenAt: RECENT,
});
expect(r.success).toBe(false);
});
it("does not require a unit for a qualitative reading", () => {
const r = createLabResultSchema.safeParse({
analyte: "Serology",
valueText: "negativ",
takenAt: RECENT,
});
expect(r.success).toBe(true);
});
});
describe("updateLabResultSchema", () => {
@@ -139,6 +205,19 @@ describe("updateLabResultSchema", () => {
expect(r.success).toBe(true);
});
it("accepts a partial valueText-only edit (v1.18.9)", () => {
const r = updateLabResultSchema.safeParse({ valueText: "positiv" });
expect(r.success).toBe(true);
});
it("rejects an edit carrying both value and valueText", () => {
const r = updateLabResultSchema.safeParse({
value: 5.6,
valueText: "negativ",
});
expect(r.success).toBe(false);
});
it("still enforces the range order when both bounds are supplied", () => {
const r = updateLabResultSchema.safeParse({
referenceLow: 10,
+43 -6
View File
@@ -72,12 +72,22 @@ const referenceHigh = z.number().finite().optional();
*/
const biomarkerId = z.string().trim().min(1).max(64).optional();
/**
* v1.18.9 — a qualitative reading ("negativ" / "positiv" / "grenzwertig" /
* "nicht nachweisbar"). Trimmed, non-empty, sensibly capped. Mutually
* exclusive with the numeric `value` (see the create/update refinements).
*/
const valueText = requiredText(120).optional();
export const createLabResultSchema = z
.object({
biomarkerId,
panel: optionalPanel,
analyte: requiredText(120).optional(),
value: z.number().finite(),
// v1.18.9 — `value` is no longer required: a reading is EITHER numeric
// (`value`) OR qualitative (`valueText`), never both, never neither.
value: z.number().finite().optional(),
valueText,
unit: requiredText(40).optional(),
referenceLow,
referenceHigh,
@@ -89,14 +99,31 @@ export const createLabResultSchema = z
message: "Either biomarkerId or analyte is required",
path: ["analyte"],
})
// Without a catalog link the unit is required (the catalog otherwise
// supplies it server-side).
.refine((d) => d.biomarkerId !== undefined || d.unit !== undefined, {
message: "unit is required when no biomarkerId is given",
path: ["unit"],
// Numeric XOR qualitative: exactly one of `value` / `valueText` is set.
.refine((d) => (d.value !== undefined) !== (d.valueText !== undefined), {
message:
"Provide exactly one of value (numeric) or valueText (qualitative)",
path: ["value"],
})
// A numeric reading without a catalog link still needs a unit (the catalog
// otherwise supplies it server-side). A qualitative reading needs no unit —
// its result is the text, and a unit/range is meaningless for it.
.refine(
(d) =>
d.valueText !== undefined ||
d.biomarkerId !== undefined ||
d.unit !== undefined,
{
message:
"unit is required for a numeric reading when no biomarkerId is given",
path: ["unit"],
},
)
// Reference bounds apply to the numeric path only; skip them entirely for a
// qualitative reading.
.refine(
(d) =>
d.valueText !== undefined ||
d.referenceLow === undefined ||
d.referenceHigh === undefined ||
d.referenceLow <= d.referenceHigh,
@@ -118,12 +145,22 @@ export const updateLabResultSchema = z
panel: optionalPanel.or(z.null()),
analyte: requiredText(120).optional(),
value: z.number().finite().optional(),
// v1.18.9 — edit the qualitative result. A row stays single-typed: a PUT
// must not set BOTH `value` and `valueText` (the refine below). Switching a
// row's type (numeric ↔ qualitative) is intentionally not supported here —
// delete and re-add — so an omitted key leaves the row's existing type.
valueText: requiredText(120).optional(),
unit: requiredText(40).optional(),
referenceLow: referenceLow.or(z.null()),
referenceHigh: referenceHigh.or(z.null()),
takenAt: takenAtField.optional(),
note: optionalNote.or(z.null()),
})
// A single edit never carries both a numeric and a qualitative value.
.refine((d) => d.value === undefined || d.valueText === undefined, {
message: "Provide value (numeric) or valueText (qualitative), not both",
path: ["value"],
})
.refine(
(d) =>
d.referenceLow === undefined ||