diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 12e8aed5c..6ecb970d7 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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 diff --git a/messages/de.json b/messages/de.json index 366e5ba2e..535b44c94 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 45e6a3f98..b2372522b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index 983170479..be2580372 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index b79b3ce16..4cbe3b34f 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index 09a66cf1a..5866e29ba 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index 77f4a881d..1acef823f 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/prisma/migrations/0181_v1189_qualitative_lab_results/migration.sql b/prisma/migrations/0181_v1189_qualitative_lab_results/migration.sql new file mode 100644 index 000000000..700ee7365 --- /dev/null +++ b/prisma/migrations/0181_v1189_qualitative_lab_results/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7cac927df..deeb77edf 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") diff --git a/src/app/api/labs/[id]/route.ts b/src/app/api/labs/[id]/route.ts index 39bd061e5..ca5307478 100644 --- a/src/app/api/labs/[id]/route.ts +++ b/src/app/api/labs/[id]/route.ts @@ -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; diff --git a/src/app/api/labs/route.ts b/src/app/api/labs/route.ts index dce2e5a93..0c4821d02 100644 --- a/src/app/api/labs/route.ts +++ b/src/app/api/labs/route.ts @@ -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, diff --git a/src/components/labs/__tests__/lab-form-result-type.test.tsx b/src/components/labs/__tests__/lab-form-result-type.test.tsx new file mode 100644 index 000000000..73c073ef2 --- /dev/null +++ b/src/components/labs/__tests__/lab-form-result-type.test.tsx @@ -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( + + {node} + , + ); +} + +describe(" result-type toggle", () => { + it("renders both numeric and qualitative mode options", () => { + const html = render(); + 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(); + // 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"'); + }); +}); diff --git a/src/components/labs/lab-biomarker-chart.tsx b/src/components/labs/lab-biomarker-chart.tsx index 21c05ef95..c1a5521e7 100644 --- a/src/components/labs/lab-biomarker-chart.tsx +++ b/src/components/labs/lab-biomarker-chart.tsx @@ -68,9 +68,14 @@ export function LabBiomarkerChart({ const [nowMs] = useState(() => Date.now()); const points = useMemo(() => { - 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 diff --git a/src/components/labs/lab-biomarker-detail.tsx b/src/components/labs/lab-biomarker-detail.tsx index 80e5f43da..436ec36d3 100644 --- a/src/components/labs/lab-biomarker-detail.tsx +++ b/src/components/labs/lab-biomarker-detail.tsx @@ -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 (
@@ -147,10 +149,17 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { {latest ? (
- {formatLabValue(latest.value)}{" "} - - {marker?.unit ?? latest.unit} - + {latest.value !== null ? ( + <> + {formatLabValue(latest.value)}{" "} + + {marker?.unit ?? latest.unit} + + + ) : ( + // Qualitative latest reading — show the result text, no unit. + (latest.valueText ?? "") + )}
diff --git a/src/components/labs/lab-form.tsx b/src/components/labs/lab-form.tsx index 1e8117cfb..97dc2464f 100644 --- a/src/components/labs/lab-form.tsx +++ b/src/components/labs/lab-form.tsx @@ -93,7 +93,14 @@ export function LabForm({ const [biomarkerId, setBiomarkerId] = useState( 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("/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}
+
+ +
+ + +
+
+
- - setValue(e.target.value)} - placeholder="0.0" - required - /> + {resultType === "numeric" ? ( + <> + + setValue(e.target.value)} + placeholder="0.0" + required + /> + + ) : ( + <> + + setValueText(e.target.value)} + placeholder={t("labs.form.qualitativePlaceholder")} + maxLength={120} + required + /> + + + + )}
@@ -248,7 +320,7 @@ export function LabForm({
- {selected && referenceText ? ( + {resultType === "numeric" && selected && referenceText ? (

{t("labs.referenceLabel")} {referenceText} {selected.unit}

diff --git a/src/components/labs/lab-history-list.tsx b/src/components/labs/lab-history-list.tsx index c094bb45a..49fd352bc 100644 --- a/src/components/labs/lab-history-list.tsx +++ b/src/components/labs/lab-history-list.tsx @@ -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(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(`/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[] }) {
- {formatLabValue(r.value)} {r.unit} + {formatLabReading(r)}
@@ -236,14 +258,28 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
- - setEditValue(e.target.value)} - required - /> + + {editIsQualitative ? ( + setEditValueText(e.target.value)} + maxLength={120} + required + /> + ) : ( + setEditValue(e.target.value)} + required + /> + )}
diff --git a/src/components/labs/lab-list.tsx b/src/components/labs/lab-list.tsx index d386be609..bf3b0a2f8 100644 --- a/src/components/labs/lab-list.tsx +++ b/src/components/labs/lab-list.tsx @@ -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 } = {}) {
- {formatLabValue(group.latest.value)} {group.latest.unit} + {formatLabReading(group.latest)} {formatDate(group.latest.takenAt)}
@@ -236,10 +236,11 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
- {formatLabValue(group.latest.value)} {group.latest.unit} + {formatLabReading(group.latest)} - {group.latest.referenceLow !== null || - group.latest.referenceHigh !== null ? ( + {group.latest.value !== null && + (group.latest.referenceLow !== null || + group.latest.referenceHigh !== null) ? ( {t("labs.referenceLabel")}{" "} {formatReferenceRange( diff --git a/src/components/labs/lab-trend-sparkline.tsx b/src/components/labs/lab-trend-sparkline.tsx index af915743d..1395e1dc9 100644 --- a/src/components/labs/lab-trend-sparkline.tsx +++ b/src/components/labs/lab-trend-sparkline.tsx @@ -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); diff --git a/src/components/labs/types.ts b/src/components/labs/types.ts index 315436fb7..1c10ef77a 100644 --- a/src/components/labs/types.ts +++ b/src/components/labs/types.ts @@ -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; diff --git a/src/lib/doctor-report-data.ts b/src/lib/doctor-report-data.ts index 48ceef4a8..bc2fc0702 100644 --- a/src/lib/doctor-report-data.ts +++ b/src/lib/doctor-report-data.ts @@ -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, diff --git a/src/lib/doctor-report-pdf-core.ts b/src/lib/doctor-report-pdf-core.ts index 306fbab4e..ced3263df 100644 --- a/src/lib/doctor-report-pdf-core.ts +++ b/src/lib/doctor-report-pdf-core.ts @@ -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, diff --git a/src/lib/fhir/__tests__/lab-observations.test.ts b/src/lib/fhir/__tests__/lab-observations.test.ts index b3fed82b0..d2f647f5a 100644 --- a/src/lib/fhir/__tests__/lab-observations.test.ts +++ b/src/lib/fhir/__tests__/lab-observations.test.ts @@ -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(); + }); +}); diff --git a/src/lib/fhir/lab-qualitative.ts b/src/lib/fhir/lab-qualitative.ts new file mode 100644 index 000000000..fb614e39c --- /dev/null +++ b/src/lib/fhir/lab-qualitative.ts @@ -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 = { + // 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, + }; +} diff --git a/src/lib/fhir/resources.ts b/src/lib/fhir/resources.ts index 68cbda343..1edbd0932 100644 --- a/src/lib/fhir/resources.ts +++ b/src/lib/fhir/resources.ts @@ -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: { diff --git a/src/lib/labs/__tests__/serialise.test.ts b/src/lib/labs/__tests__/serialise.test.ts index d8aa335ad..f2dd77cd2 100644 --- a/src/lib/labs/__tests__/serialise.test.ts +++ b/src/lib/labs/__tests__/serialise.test.ts @@ -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"); + }); +}); diff --git a/src/lib/labs/format-value.ts b/src/lib/labs/format-value.ts index 768a031cb..250a6bffc 100644 --- a/src/lib/labs/format-value.ts +++ b/src/lib/labs/format-value.ts @@ -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(); +} diff --git a/src/lib/labs/serialise.ts b/src/lib/labs/serialise.ts index 77a8cf29c..adbe528dc 100644 --- a/src/lib/labs/serialise.ts +++ b/src/lib/labs/serialise.ts @@ -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, diff --git a/src/lib/openapi/routes/labs.ts b/src/lib/openapi/routes/labs.ts index e2ee03bde..5d20fc2d6 100644 --- a/src/lib/openapi/routes/labs.ts +++ b/src/lib/openapi/routes/labs.ts @@ -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 diff --git a/src/lib/validations/__tests__/labs.test.ts b/src/lib/validations/__tests__/labs.test.ts index 3f090b01e..2a4a65cac 100644 --- a/src/lib/validations/__tests__/labs.test.ts +++ b/src/lib/validations/__tests__/labs.test.ts @@ -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, diff --git a/src/lib/validations/labs.ts b/src/lib/validations/labs.ts index 20f2f9e66..93bbd4359 100644 --- a/src/lib/validations/labs.ts +++ b/src/lib/validations/labs.ts @@ -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 ||