@@ -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 ||