mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
1 parent
e631396af3
commit
a5b3715a46
30 files changed
+819
-123
No files matched your search
+30
-7
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
@@ -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")
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
Reference in new issue
Block a user