fix(labs): never present a converted reading's printed range in the wrong unit

A converted reading's bounds move with its value, but the printed range text
stays in the unit the report used, and every reader preferred that text: the
list showed "Report range 3.9 to 5.5" beside 100.9 mg/dL, and the doctor
report and the FHIR referenceRange.text did the same. The text is still
stored verbatim. The range resolver now takes the reading's sourceUnit (as a
required field, so no reader can forget to select it) and, for a converted
reading, leaves the window's text empty so every surface states the converted
bounds in the marker's unit, and hands the printed text over separately with
its unit. The lab DTO carries it as printedReferenceText, and the note beside
a reading shows it as "as printed in mmol/L: ...".
This commit is contained in:
Marc-André Bombeck committed 2026-10-14 19:10:49 +02:00
1 parent cc0313c955
commit 47a91d6922
29 files changed
+365 -20

No files matched your search

+1
View File
@@ -143,6 +143,7 @@ export async function buildLabsSnapshotBlock(
sourceReferenceLow: true,
sourceReferenceHigh: true,
sourceReferenceText: true,
sourceUnit: true,
takenAt: true,
biomarkerId: true,
biomarker: {
@@ -56,6 +56,7 @@ export async function loadLabResults(
sourceReferenceLow: true,
sourceReferenceHigh: true,
sourceReferenceText: true,
sourceUnit: true,
takenAt: true,
},
});
@@ -165,6 +165,47 @@ describe("lab-result FHIR Observations", () => {
expect(crp?.referenceRange).toBeUndefined();
});
it("states a converted reading's window only in the value's unit", () => {
// A reading converted from mmol/L: the resolved entry carries the bounds
// in mg/dL and no text (the printed text is in mmol/L and would disagree
// with low and high), so the element carries the two bounds alone.
const bundle = buildFhirDocumentBundle(
makeData({
labResults: [
{
panel: null,
analyte: "Glucose",
value: 100.9,
valueText: null,
unit: "mg/dL",
referenceLow: 70.27,
referenceHigh: 99.1,
catalogReferenceLow: 70,
catalogReferenceHigh: 100,
sourceReferenceText: null,
referenceOrigin: "source" as const,
referenceDivergesFromCatalog: true,
takenAt: "2026-04-10T08:00:00.000Z",
count: 1,
},
],
}),
{ insuranceNumber: "A123456780" },
FIXED_NOW,
);
const [obs] = observationsOf(bundle).filter((o) =>
o.category?.some((c) =>
c.coding?.some((code) => code.code === "laboratory"),
),
);
expect(obs.referenceRange).toEqual([
{
low: expect.objectContaining({ value: 70.27, unit: "mg/dL" }),
high: expect.objectContaining({ value: 99.1, unit: "mg/dL" }),
},
]);
});
it("emits no lab Observations when labResults is null", () => {
const bundle = buildFhirDocumentBundle(
makeData({ labResults: null }),
@@ -37,6 +37,7 @@ function row(
sourceReferenceLow: opts.sourceLow ?? null,
sourceReferenceHigh: opts.sourceHigh ?? null,
sourceReferenceText: opts.sourceText ?? null,
sourceUnit: null,
takenAt: new Date(`${day}T08:00:00.000Z`),
};
}
+1
View File
@@ -172,6 +172,7 @@ export async function generateBiomarkerStatus(args: {
sourceReferenceLow: true,
sourceReferenceHigh: true,
sourceReferenceText: true,
sourceUnit: true,
},
});
if (rows.length === 0) {
@@ -1072,6 +1072,7 @@ export async function fetchLabDraws(
sourceReferenceLow: true,
sourceReferenceHigh: true,
sourceReferenceText: true,
sourceUnit: true,
panel: true,
value: true,
takenAt: true,
+1
View File
@@ -256,6 +256,7 @@ export async function readLabsBriefingBlock(
sourceReferenceLow: true,
sourceReferenceHigh: true,
sourceReferenceText: true,
sourceUnit: true,
takenAt: true,
biomarkerId: true,
biomarker: {
@@ -9,6 +9,7 @@ const NO_SOURCE = {
sourceReferenceLow: null,
sourceReferenceHigh: null,
sourceReferenceText: null,
sourceUnit: null,
};
describe("resolveEffectiveReferenceRange", () => {
@@ -36,6 +37,7 @@ describe("resolveEffectiveReferenceRange", () => {
sourceReferenceLow: 3.9,
sourceReferenceHigh: 5.4,
sourceReferenceText: "3,9 - 5,4",
sourceUnit: null,
});
expect(eff).toMatchObject({
low: 3.9,
@@ -53,6 +55,7 @@ describe("resolveEffectiveReferenceRange", () => {
sourceReferenceLow: 3.5,
sourceReferenceHigh: 5,
sourceReferenceText: "3,5 - 5,0",
sourceUnit: null,
});
expect(eff.origin).toBe("source");
expect(eff.divergesFromCatalog).toBe(false);
@@ -65,6 +68,7 @@ describe("resolveEffectiveReferenceRange", () => {
sourceReferenceLow: null,
sourceReferenceHigh: 4.2,
sourceReferenceText: "< 4,2",
sourceUnit: null,
});
expect(eff).toMatchObject({
low: null,
@@ -81,6 +85,7 @@ describe("resolveEffectiveReferenceRange", () => {
sourceReferenceLow: null,
sourceReferenceHigh: null,
sourceReferenceText: "negativ",
sourceUnit: null,
});
expect(eff).toMatchObject({
low: 3.5,
@@ -96,12 +101,57 @@ describe("resolveEffectiveReferenceRange", () => {
sourceReferenceLow: 3.9,
sourceReferenceHigh: 5.4,
sourceReferenceText: "3,9 - 5,4",
sourceUnit: null,
});
expect(eff.origin).toBe("source");
expect(eff.divergesFromCatalog).toBe(false);
});
});
describe("a converted reading's printed text", () => {
// 5.6 mmol/L stored as mg/dL, with the printed 3.9 to 5.5 mmol/L window
// converted alongside; the text stays as the report printed it.
const CONVERTED = {
sourceReferenceLow: 3.9 * 18.0182,
sourceReferenceHigh: 5.5 * 18.0182,
sourceReferenceText: "3,9 - 5,5",
sourceUnit: "mmol/L",
};
it("is never the window's text, so no statement mixes two units", () => {
const eff = resolveEffectiveReferenceRange(70, 100, CONVERTED);
expect(eff.origin).toBe("source");
expect(eff.low).toBeCloseTo(70.27, 2);
expect(eff.sourceText).toBeNull();
});
it("is kept as evidence with the unit it is written in", () => {
const eff = resolveEffectiveReferenceRange(70, 100, CONVERTED);
expect(eff.printed).toEqual({ text: "3,9 - 5,5", unit: "mmol/L" });
});
it("stays the window's text for a reading stored as stated", () => {
const eff = resolveEffectiveReferenceRange(70, 100, {
...CONVERTED,
sourceUnit: null,
});
expect(eff.sourceText).toBe("3,9 - 5,5");
expect(eff.printed).toBeNull();
});
it("moves an unreadable printed text the same way", () => {
const eff = resolveEffectiveReferenceRange(70, 100, {
sourceReferenceLow: null,
sourceReferenceHigh: null,
sourceReferenceText: "siehe Befund",
sourceUnit: "mmol/L",
});
expect(eff.origin).toBe("catalog");
expect(eff.sourceText).toBeNull();
expect(eff.printed).toEqual({ text: "siehe Befund", unit: "mmol/L" });
});
});
describe("classifyAgainstEffectiveRange", () => {
it("judges a reading by the source window, not the catalog band", () => {
// 5.2 sits inside the catalog band (3.5–5.0 would call it high) but the
@@ -110,6 +160,7 @@ describe("classifyAgainstEffectiveRange", () => {
sourceReferenceLow: 3.9,
sourceReferenceHigh: 5.4,
sourceReferenceText: "3,9 - 5,4",
sourceUnit: null,
});
expect(classifyAgainstEffectiveRange(5.2, eff)).toBe("in-range");
expect(classifyAgainstEffectiveRange(3.7, eff)).toBe("below");
+2
View File
@@ -20,6 +20,8 @@ const baseRow: LabRow = {
sourceReferenceLow: null,
sourceReferenceHigh: null,
sourceReferenceText: null,
sourceValue: null,
sourceUnit: null,
takenAt: new Date("2026-01-01T08:00:00.000Z"),
source: "MANUAL",
biomarkerId: "bm_1",
+35 -2
View File
@@ -65,11 +65,23 @@ export function formatReferenceRange(
/**
* The reference range a lab report printed beside ONE reading, as stored on
* `LabResult.sourceReference*`.
*
* `sourceUnit` is set when the reading was converted into its marker's unit:
* the bounds were converted with the value, but the text is the report's own
* and stays in the unit the report printed. It is required, so a reader cannot
* forget to select it and present that text beside numbers in another unit.
*/
export interface SourceReferenceRange {
sourceReferenceLow: number | null;
sourceReferenceHigh: number | null;
sourceReferenceText: string | null;
sourceUnit: string | null;
}
/** A converted reading's printed range text, with the unit it is written in. */
export interface PrintedReferenceRange {
text: string;
unit: string;
}
/** Where the bounds a reading is judged against came from. */
@@ -84,8 +96,19 @@ export interface EffectiveReferenceRange {
/** The catalog band, kept alongside so a surface can show both. */
catalogLow: number | null;
catalogHigh: number | null;
/** The printed window, kept verbatim. Null when the report stated none. */
/**
* The printed window, kept verbatim, in the reading's unit. Null when the
* report stated none, and null for a converted reading: its text is in the
* unit the report printed, not the unit of the value and bounds beside it,
* and moves to `printed`.
*/
sourceText: string | null;
/**
* For a converted reading only: the window as the report printed it and the
* unit it is in. Evidence, never a statement about the stored numbers; a
* surface that shows it names the unit with it.
*/
printed: PrintedReferenceRange | null;
/**
* True when the source window is in force AND the catalog states a window
* that differs from it. This is the case a reader must be told about: the
@@ -132,7 +155,15 @@ export function resolveEffectiveReferenceRange(
const cHigh = catalogHigh ?? null;
const sLow = source?.sourceReferenceLow ?? null;
const sHigh = source?.sourceReferenceHigh ?? null;
const sourceText = source?.sourceReferenceText ?? null;
const verbatim = source?.sourceReferenceText ?? null;
const sourceUnit = source?.sourceUnit ?? null;
// A converted reading's text is in the printed unit; it is never presented as
// the window of the converted value.
const sourceText = sourceUnit === null ? verbatim : null;
const printed: PrintedReferenceRange | null =
sourceUnit !== null && verbatim !== null
? { text: verbatim, unit: sourceUnit }
: null;
if (sLow !== null || sHigh !== null) {
return {
@@ -142,6 +173,7 @@ export function resolveEffectiveReferenceRange(
catalogLow: cLow,
catalogHigh: cHigh,
sourceText,
printed,
divergesFromCatalog:
(cLow !== null || cHigh !== null) &&
!sameWindow(sLow, sHigh, cLow, cHigh),
@@ -155,6 +187,7 @@ export function resolveEffectiveReferenceRange(
catalogLow: cLow,
catalogHigh: cHigh,
sourceText,
printed,
divergesFromCatalog: false,
};
}
+17 -3
View File
@@ -25,7 +25,11 @@
* - `catalogReferenceLow` / `catalogReferenceHigh` — the catalog band, so a
* surface can paint both and show where they part.
* - `sourceReferenceLow` / `sourceReferenceHigh` / `sourceReferenceText` —
* what the report printed, the last verbatim.
* what the report printed, the last verbatim. For a reading converted into
* its marker's unit the bounds were converted with the value, but the text
* is still in the unit the report printed, so it is never sent as
* `sourceReferenceText` (that would put two units in one statement): it
* travels as `printedReferenceText`, in `sourceUnit`.
*
* `referenceOrigin` names which window is in force and
* `referenceDivergesFromCatalog` flags the case a reader must be told about.
@@ -80,8 +84,8 @@ export interface LabRow {
* When the reading was converted into the marker's unit: the value and unit
* as the report printed them. Both null for a reading stored as stated.
*/
sourceValue?: number | null;
sourceUnit?: string | null;
sourceValue: number | null;
sourceUnit: string | null;
noteEncrypted: Uint8Array | null;
createdAt: Date;
updatedAt: Date;
@@ -97,6 +101,7 @@ export type LabFieldRow = Pick<
| "sourceReferenceLow"
| "sourceReferenceHigh"
| "sourceReferenceText"
| "sourceUnit"
| "panel"
| "biomarkerId"
>;
@@ -115,7 +120,13 @@ export interface ResolvedLabFields {
/** What the source report printed for this reading. */
sourceReferenceLow: number | null;
sourceReferenceHigh: number | null;
/** Null for a converted reading; see `printedReferenceText`. */
sourceReferenceText: string | null;
/**
* A converted reading's printed range text, verbatim, in `sourceUnit` (the
* unit the report printed); null for every other reading.
*/
printedReferenceText: string | null;
referenceOrigin: ReferenceRangeOrigin;
referenceDivergesFromCatalog: boolean;
/** The resolved window as one value, for callers that pass it on. */
@@ -172,6 +183,7 @@ export function resolveLabFields(
sourceReferenceLow: row.sourceReferenceLow,
sourceReferenceHigh: row.sourceReferenceHigh,
sourceReferenceText: effectiveRange.sourceText,
printedReferenceText: effectiveRange.printed?.text ?? null,
referenceOrigin: effectiveRange.origin,
referenceDivergesFromCatalog: effectiveRange.divergesFromCatalog,
effectiveRange,
@@ -206,6 +218,7 @@ export function serialiseLabResult(
sourceReferenceLow: resolved.sourceReferenceLow,
sourceReferenceHigh: resolved.sourceReferenceHigh,
sourceReferenceText: resolved.sourceReferenceText,
printedReferenceText: resolved.printedReferenceText,
referenceOrigin: resolved.referenceOrigin,
referenceDivergesFromCatalog: resolved.referenceDivergesFromCatalog,
takenAt: row.takenAt.toISOString(),
@@ -265,6 +278,7 @@ export function serialiseLabResultDetail(
sourceReferenceLow: resolved.sourceReferenceLow,
sourceReferenceHigh: resolved.sourceReferenceHigh,
sourceReferenceText: resolved.sourceReferenceText,
printedReferenceText: resolved.printedReferenceText,
referenceOrigin: resolved.referenceOrigin,
referenceDivergesFromCatalog: resolved.referenceDivergesFromCatalog,
takenAt: row.takenAt.toISOString(),
+1
View File
@@ -1426,6 +1426,7 @@ export async function getLabHistory(
sourceReferenceLow: true,
sourceReferenceHigh: true,
sourceReferenceText: true,
sourceUnit: true,
takenAt: true,
biomarkerId: true,
biomarker: {
+2 -1
View File
@@ -80,6 +80,7 @@ const labResultRow = z
sourceReferenceLow: z.number().nullable(),
sourceReferenceHigh: z.number().nullable(),
sourceReferenceText: z.string().nullable(),
printedReferenceText: z.string().nullable(),
referenceOrigin: referenceOriginEnum,
referenceDivergesFromCatalog: z.boolean(),
takenAt: z.string(),
@@ -92,7 +93,7 @@ const labResultRow = z
.meta({
id: "LabResult",
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` are RESOLVED server-side from the Biomarker.\n\nThree reference windows ride on the row and the server says which one applies. `referenceLow` / `referenceHigh` are the window IN FORCE and the one `rangeStatus` was computed from — read these to render the range and you are correct without doing any resolution of your own. `sourceReferenceLow` / `sourceReferenceHigh` / `sourceReferenceText` are the range this reading's own report printed (the text verbatim, kept even when no numbers could be read out of it). `catalogReferenceLow` / `catalogReferenceHigh` are the Biomarker band. `referenceOrigin` names the winner and `referenceDivergesFromCatalog` is true when the report's window and the catalog band state different limits — a surface showing only one of the two is showing a partial answer. The client renders these values and never recomputes the range. `converted` is true when the stored `value` was converted into the marker's `unit` from another unit; `sourceValue` and `sourceUnit` are then what the report printed (full precision, round for display), and the report's own window was converted with it. A reading stored as stated has `converted: false` and two nulls. 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` are RESOLVED server-side from the Biomarker.\n\nThree reference windows ride on the row and the server says which one applies. `referenceLow` / `referenceHigh` are the window IN FORCE and the one `rangeStatus` was computed from — read these to render the range and you are correct without doing any resolution of your own. `sourceReferenceLow` / `sourceReferenceHigh` / `sourceReferenceText` are the range this reading's own report printed (the text verbatim, kept even when no numbers could be read out of it). `catalogReferenceLow` / `catalogReferenceHigh` are the Biomarker band. `referenceOrigin` names the winner and `referenceDivergesFromCatalog` is true when the report's window and the catalog band state different limits — a surface showing only one of the two is showing a partial answer. The client renders these values and never recomputes the range. `converted` is true when the stored `value` was converted into the marker's `unit` from another unit; `sourceValue` and `sourceUnit` are then what the report printed (full precision, round for display), and the report's own window was converted with it: `sourceReferenceLow` / `sourceReferenceHigh` are then in the marker's `unit`. The printed range text is not converted and stays in `sourceUnit`, so for a converted reading `sourceReferenceText` is null and the verbatim text is in `printedReferenceText` instead, never mixed with the converted numbers. A reading stored as stated has `converted: false`, two nulls and a null `printedReferenceText`. 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
+2 -1
View File
@@ -129,6 +129,7 @@ const committedRow = z
sourceReferenceLow: z.number().nullable(),
sourceReferenceHigh: z.number().nullable(),
sourceReferenceText: z.string().nullable(),
printedReferenceText: z.string().nullable(),
referenceOrigin: z.enum(["source", "catalog", "none"]),
referenceDivergesFromCatalog: z.boolean(),
takenAt: z.string(),
@@ -141,7 +142,7 @@ const committedRow = z
.meta({
id: "OcrCommittedLabResult",
description:
"A written reading. Its reference window resolves exactly as it does on `/api/labs` — see `LabResult`. `converted`, `sourceValue` and `sourceUnit` say when the row's unit was converted into its marker's and what the report printed.",
"A written reading. Its reference window resolves exactly as it does on `/api/labs` — see `LabResult`. `converted`, `sourceValue` and `sourceUnit` say when the row's unit was converted into its marker's and what the report printed; for a converted row the printed range text is in `printedReferenceText` (in `sourceUnit`) and `sourceReferenceText` is null.",
});
const commitResponse = z