From 47a91d69229ea81fd25ce50fcd3ee6ffede01edb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 14 Oct 2026 19:10:49 +0200 Subject: [PATCH] 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: ...". --- docs/api/openapi.yaml | 27 ++++++- messages/de.json | 3 +- messages/en.json | 3 +- messages/es.json | 3 +- messages/fr.json | 3 +- messages/it.json | 3 +- messages/ko.json | 3 +- messages/pl.json | 3 +- ...en-outcome-response-consumer-guard.test.ts | 1 + src/app/api/insights/labs-changes/route.ts | 2 + .../__tests__/lab-biomarker-detail.test.tsx | 1 + .../labs/__tests__/source-range-note.test.tsx | 73 +++++++++++++++++++ src/components/labs/source-range-note.tsx | 19 ++++- src/components/labs/types.ts | 5 ++ src/lib/ai/coach/labs-snapshot.ts | 1 + src/lib/doctor-report/clinical-records.ts | 1 + .../fhir/__tests__/lab-observations.test.ts | 41 +++++++++++ .../insights/__tests__/labs-changes.test.ts | 1 + src/lib/insights/biomarker-status.ts | 1 + .../insights/correlation-channel-series.ts | 1 + src/lib/insights/feature-blocks.ts | 1 + .../effective-reference-range.test.ts | 51 +++++++++++++ src/lib/labs/__tests__/serialise.test.ts | 2 + src/lib/labs/reference-range.ts | 37 +++++++++- src/lib/labs/serialise.ts | 20 ++++- src/lib/mcp/rich-reads.ts | 1 + src/lib/openapi/routes/labs.ts | 3 +- src/lib/openapi/routes/ocr.ts | 3 +- .../lab-source-reference-range.test.ts | 72 ++++++++++++++++++ 29 files changed, 365 insertions(+), 20 deletions(-) create mode 100644 src/components/labs/__tests__/source-range-note.test.tsx diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 90ae0fee6..c45800cf0 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -67435,6 +67435,10 @@ components: anyOf: - type: string - type: "null" + printedReferenceText: + anyOf: + - type: string + - type: "null" referenceOrigin: $ref: "#/components/schemas/LabReferenceRangeOrigin" referenceDivergesFromCatalog: @@ -67469,6 +67473,7 @@ components: - sourceReferenceLow - sourceReferenceHigh - sourceReferenceText + - printedReferenceText - referenceOrigin - referenceDivergesFromCatalog - takenAt @@ -67494,9 +67499,12 @@ components: 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`. + 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`. LabReferenceRangeOrigin: type: string enum: @@ -67613,6 +67621,10 @@ components: anyOf: - type: string - type: "null" + printedReferenceText: + anyOf: + - type: string + - type: "null" referenceOrigin: $ref: "#/components/schemas/LabReferenceRangeOrigin" referenceDivergesFromCatalog: @@ -67649,6 +67661,7 @@ components: - sourceReferenceLow - sourceReferenceHigh - sourceReferenceText + - printedReferenceText - referenceOrigin - referenceDivergesFromCatalog - takenAt @@ -67938,6 +67951,10 @@ components: anyOf: - type: string - type: "null" + printedReferenceText: + anyOf: + - type: string + - type: "null" referenceOrigin: type: string enum: @@ -67981,6 +67998,7 @@ components: - sourceReferenceLow - sourceReferenceHigh - sourceReferenceText + - printedReferenceText - referenceOrigin - referenceDivergesFromCatalog - takenAt @@ -67992,7 +68010,8 @@ components: additionalProperties: false 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. + report printed; for a converted row the printed range text is in `printedReferenceText` (in `sourceUnit`) and + `sourceReferenceText` is null. AiRunEnvelope: type: object properties: diff --git a/messages/de.json b/messages/de.json index 2dede887c..c32f00240 100644 --- a/messages/de.json +++ b/messages/de.json @@ -9590,7 +9590,8 @@ "notLinkedYet": "Noch keinem Biomarker zugeordnet", "sourceRange": { "fromReport": "Befundbereich {range}", - "catalogDiffers": "dein Bereich {range}" + "catalogDiffers": "dein Bereich {range}", + "printed": "auf dem Befund in {unit}: {range}" } }, "modules": { diff --git a/messages/en.json b/messages/en.json index b1d26fda7..426a6a642 100644 --- a/messages/en.json +++ b/messages/en.json @@ -9590,7 +9590,8 @@ "notLinkedYet": "Not linked to a biomarker yet", "sourceRange": { "fromReport": "Report range {range}", - "catalogDiffers": "your range {range}" + "catalogDiffers": "your range {range}", + "printed": "as printed in {unit}: {range}" } }, "modules": { diff --git a/messages/es.json b/messages/es.json index 35975a9f1..ae81b8819 100644 --- a/messages/es.json +++ b/messages/es.json @@ -9590,7 +9590,8 @@ "notLinkedYet": "Aún no vinculado a un biomarcador", "sourceRange": { "fromReport": "Rango del informe {range}", - "catalogDiffers": "tu rango {range}" + "catalogDiffers": "tu rango {range}", + "printed": "impreso en {unit}: {range}" } }, "modules": { diff --git a/messages/fr.json b/messages/fr.json index fecb4638e..7a0015b4c 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -9590,7 +9590,8 @@ "notLinkedYet": "Pas encore lié à un biomarqueur", "sourceRange": { "fromReport": "Plage du compte rendu {range}", - "catalogDiffers": "votre plage {range}" + "catalogDiffers": "votre plage {range}", + "printed": "imprimé en {unit} : {range}" } }, "modules": { diff --git a/messages/it.json b/messages/it.json index 122e85b73..e123c3a92 100644 --- a/messages/it.json +++ b/messages/it.json @@ -9590,7 +9590,8 @@ "notLinkedYet": "Non ancora collegato a un biomarcatore", "sourceRange": { "fromReport": "Intervallo del referto {range}", - "catalogDiffers": "il tuo intervallo {range}" + "catalogDiffers": "il tuo intervallo {range}", + "printed": "stampato in {unit}: {range}" } }, "modules": { diff --git a/messages/ko.json b/messages/ko.json index 61cd6cee5..532e4641f 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -9590,7 +9590,8 @@ "notLinkedYet": "아직 바이오마커에 연결되지 않았어요", "sourceRange": { "fromReport": "결과지 범위 {range}", - "catalogDiffers": "내 범위 {range}" + "catalogDiffers": "내 범위 {range}", + "printed": "결과지 원문({unit}): {range}" } }, "modules": { diff --git a/messages/pl.json b/messages/pl.json index 51fd76613..816d930d7 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -9590,7 +9590,8 @@ "notLinkedYet": "Jeszcze niepowiązane z biomarkerem", "sourceRange": { "fromReport": "Zakres z wyniku {range}", - "catalogDiffers": "twój zakres {range}" + "catalogDiffers": "twój zakres {range}", + "printed": "wydrukowano w {unit}: {range}" } }, "modules": { diff --git a/src/__tests__/written-outcome-response-consumer-guard.test.ts b/src/__tests__/written-outcome-response-consumer-guard.test.ts index ed5468bf3..bc9948329 100644 --- a/src/__tests__/written-outcome-response-consumer-guard.test.ts +++ b/src/__tests__/written-outcome-response-consumer-guard.test.ts @@ -732,6 +732,7 @@ const OCR_EXCEPTIONS: Readonly> = { "inserted.sourceReferenceLow": OCR_ROW_EXCEPTION, "inserted.sourceReferenceHigh": OCR_ROW_EXCEPTION, "inserted.sourceReferenceText": OCR_ROW_EXCEPTION, + "inserted.printedReferenceText": OCR_ROW_EXCEPTION, "inserted.referenceOrigin": OCR_ROW_EXCEPTION, "inserted.referenceDivergesFromCatalog": OCR_ROW_EXCEPTION, "inserted.takenAt": OCR_ROW_EXCEPTION, diff --git a/src/app/api/insights/labs-changes/route.ts b/src/app/api/insights/labs-changes/route.ts index 6969ac69d..d48a6b69f 100644 --- a/src/app/api/insights/labs-changes/route.ts +++ b/src/app/api/insights/labs-changes/route.ts @@ -52,6 +52,7 @@ export const GET = apiHandler(async () => { sourceReferenceLow: true, sourceReferenceHigh: true, sourceReferenceText: true, + sourceUnit: true, takenAt: true, }, orderBy: { takenAt: "desc" }, @@ -67,6 +68,7 @@ export const GET = apiHandler(async () => { sourceReferenceLow: r.sourceReferenceLow, sourceReferenceHigh: r.sourceReferenceHigh, sourceReferenceText: r.sourceReferenceText, + sourceUnit: r.sourceUnit, takenAt: r.takenAt, })), ); diff --git a/src/components/labs/__tests__/lab-biomarker-detail.test.tsx b/src/components/labs/__tests__/lab-biomarker-detail.test.tsx index 09995ac6a..f2f966b9b 100644 --- a/src/components/labs/__tests__/lab-biomarker-detail.test.tsx +++ b/src/components/labs/__tests__/lab-biomarker-detail.test.tsx @@ -78,6 +78,7 @@ function reading(id: string, value: number, takenAt: string): LabResultDto { sourceReferenceLow: null, sourceReferenceHigh: null, sourceReferenceText: null, + printedReferenceText: null, referenceOrigin: "catalog", referenceDivergesFromCatalog: false, takenAt, diff --git a/src/components/labs/__tests__/source-range-note.test.tsx b/src/components/labs/__tests__/source-range-note.test.tsx new file mode 100644 index 000000000..e77072e45 --- /dev/null +++ b/src/components/labs/__tests__/source-range-note.test.tsx @@ -0,0 +1,73 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; + +import { I18nProvider } from "@/lib/i18n/context"; + +import { SourceRangeNote } from "../source-range-note"; + +type Reading = Parameters[0]["reading"]; + +function render(reading: Reading) { + return renderToStaticMarkup( + + + , + ); +} + +const STATED: Reading = { + unit: "mg/dL", + referenceLow: 70, + referenceHigh: 99, + catalogReferenceLow: 70, + catalogReferenceHigh: 99, + sourceReferenceText: "70 - 99", + printedReferenceText: null, + sourceUnit: null, + referenceOrigin: "source", + referenceDivergesFromCatalog: false, +}; + +// 5.6 mmol/L stored as 100.9 mg/dL, with the report's 3.9 to 5.5 mmol/L window +// converted alongside it. +const CONVERTED: Reading = { + unit: "mg/dL", + referenceLow: 70.27, + referenceHigh: 99.1, + catalogReferenceLow: 70, + catalogReferenceHigh: 100, + sourceReferenceText: null, + printedReferenceText: "3.9 - 5.5", + sourceUnit: "mmol/L", + referenceOrigin: "source", + referenceDivergesFromCatalog: true, +}; + +describe("", () => { + it("names a stated reading's window verbatim", () => { + const html = render(STATED); + expect(html).toContain("Report range 70 - 99"); + expect(html).not.toContain("as printed"); + }); + + it("states a converted reading's window in the marker's unit", () => { + const html = render(CONVERTED); + expect(html).toContain("Report range 70.27–99.1 mg/dL"); + }); + + it("keeps the printed text as evidence, labelled with the unit it is in", () => { + const html = render(CONVERTED); + expect(html).toContain("as printed in mmol/L: 3.9 - 5.5"); + }); + + it("never puts the printed numbers in the statement about the stored unit", () => { + const html = render(CONVERTED); + // The window in force is followed by the marker's unit, not the printed text. + expect(html).not.toContain("Report range 3.9 - 5.5"); + expect(html).toContain("your range 70–100 mg/dL"); + }); + + it("renders nothing when the window in force is not the report's", () => { + expect(render({ ...CONVERTED, referenceOrigin: "catalog" })).toBe(""); + }); +}); diff --git a/src/components/labs/source-range-note.tsx b/src/components/labs/source-range-note.tsx index d9b4bcb87..d5e3dde2c 100644 --- a/src/components/labs/source-range-note.tsx +++ b/src/components/labs/source-range-note.tsx @@ -18,6 +18,11 @@ import { useLabNumber } from "./use-lab-format"; * the same number, so a surface that shows one silently is showing a partial * answer. * + * A reading converted into its marker's unit carries bounds that were converted + * with it, but the report's own text is still in the unit it printed. The note + * then states the window in the marker's unit and adds the printed text as + * evidence, labelled with its unit, so no single statement mixes two units. + * * Neutral by construction: no tint, no icon, muted text. The in/out verdict is * the badge's job — this element states where the window came from, nothing * about whether the value is good news. @@ -34,6 +39,8 @@ export function SourceRangeNote({ | "catalogReferenceLow" | "catalogReferenceHigh" | "sourceReferenceText" + | "printedReferenceText" + | "sourceUnit" | "referenceOrigin" | "referenceDivergesFromCatalog" >; @@ -43,7 +50,9 @@ export function SourceRangeNote({ const labNumber = useLabNumber(); if (reading.referenceOrigin !== "source") return null; - const printed = + // `sourceReferenceText` is null for a converted reading, so the window is + // then stated from the converted bounds, in the marker's unit. + const inForce = reading.sourceReferenceText ?? `${formatReferenceRange( reading.referenceLow, @@ -59,7 +68,13 @@ export function SourceRangeNote({ return ( - {t("labs.sourceRange.fromReport", { range: printed })} + {t("labs.sourceRange.fromReport", { range: inForce })} + {reading.printedReferenceText && reading.sourceUnit + ? ` · ${t("labs.sourceRange.printed", { + range: reading.printedReferenceText, + unit: reading.sourceUnit, + })}` + : null} {reading.referenceDivergesFromCatalog && catalog ? ` · ${t("labs.sourceRange.catalogDiffers", { range: `${catalog} ${reading.unit}`.trim(), diff --git a/src/components/labs/types.ts b/src/components/labs/types.ts index 60a1c2bd7..1fbfc6f44 100644 --- a/src/components/labs/types.ts +++ b/src/components/labs/types.ts @@ -38,6 +38,11 @@ export interface LabResultDto { sourceReferenceLow: number | null; sourceReferenceHigh: number | null; sourceReferenceText: string | null; + /** + * A converted reading's printed range text, verbatim and in `sourceUnit`; + * `sourceReferenceText` is then null. Null for every other reading. + */ + printedReferenceText: string | null; /** Which window is in force. */ referenceOrigin: ReferenceRangeOrigin; /** True when the report's window and the catalog band state different limits. */ diff --git a/src/lib/ai/coach/labs-snapshot.ts b/src/lib/ai/coach/labs-snapshot.ts index 90b9105ad..119c299e0 100644 --- a/src/lib/ai/coach/labs-snapshot.ts +++ b/src/lib/ai/coach/labs-snapshot.ts @@ -143,6 +143,7 @@ export async function buildLabsSnapshotBlock( sourceReferenceLow: true, sourceReferenceHigh: true, sourceReferenceText: true, + sourceUnit: true, takenAt: true, biomarkerId: true, biomarker: { diff --git a/src/lib/doctor-report/clinical-records.ts b/src/lib/doctor-report/clinical-records.ts index b22921a3f..423d42f19 100644 --- a/src/lib/doctor-report/clinical-records.ts +++ b/src/lib/doctor-report/clinical-records.ts @@ -56,6 +56,7 @@ export async function loadLabResults( sourceReferenceLow: true, sourceReferenceHigh: true, sourceReferenceText: true, + sourceUnit: true, takenAt: true, }, }); diff --git a/src/lib/fhir/__tests__/lab-observations.test.ts b/src/lib/fhir/__tests__/lab-observations.test.ts index 8ad312ffc..049958c06 100644 --- a/src/lib/fhir/__tests__/lab-observations.test.ts +++ b/src/lib/fhir/__tests__/lab-observations.test.ts @@ -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 }), diff --git a/src/lib/insights/__tests__/labs-changes.test.ts b/src/lib/insights/__tests__/labs-changes.test.ts index 0c3d8085c..71d50807a 100644 --- a/src/lib/insights/__tests__/labs-changes.test.ts +++ b/src/lib/insights/__tests__/labs-changes.test.ts @@ -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`), }; } diff --git a/src/lib/insights/biomarker-status.ts b/src/lib/insights/biomarker-status.ts index 58ba09cf4..20b9e1887 100644 --- a/src/lib/insights/biomarker-status.ts +++ b/src/lib/insights/biomarker-status.ts @@ -172,6 +172,7 @@ export async function generateBiomarkerStatus(args: { sourceReferenceLow: true, sourceReferenceHigh: true, sourceReferenceText: true, + sourceUnit: true, }, }); if (rows.length === 0) { diff --git a/src/lib/insights/correlation-channel-series.ts b/src/lib/insights/correlation-channel-series.ts index df3795b3d..a0f6d8fce 100644 --- a/src/lib/insights/correlation-channel-series.ts +++ b/src/lib/insights/correlation-channel-series.ts @@ -1072,6 +1072,7 @@ export async function fetchLabDraws( sourceReferenceLow: true, sourceReferenceHigh: true, sourceReferenceText: true, + sourceUnit: true, panel: true, value: true, takenAt: true, diff --git a/src/lib/insights/feature-blocks.ts b/src/lib/insights/feature-blocks.ts index f5f8ef356..68837ef9d 100644 --- a/src/lib/insights/feature-blocks.ts +++ b/src/lib/insights/feature-blocks.ts @@ -256,6 +256,7 @@ export async function readLabsBriefingBlock( sourceReferenceLow: true, sourceReferenceHigh: true, sourceReferenceText: true, + sourceUnit: true, takenAt: true, biomarkerId: true, biomarker: { diff --git a/src/lib/labs/__tests__/effective-reference-range.test.ts b/src/lib/labs/__tests__/effective-reference-range.test.ts index 92dc9721b..97bcd4a8b 100644 --- a/src/lib/labs/__tests__/effective-reference-range.test.ts +++ b/src/lib/labs/__tests__/effective-reference-range.test.ts @@ -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"); diff --git a/src/lib/labs/__tests__/serialise.test.ts b/src/lib/labs/__tests__/serialise.test.ts index 4ee846f49..26968217e 100644 --- a/src/lib/labs/__tests__/serialise.test.ts +++ b/src/lib/labs/__tests__/serialise.test.ts @@ -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", diff --git a/src/lib/labs/reference-range.ts b/src/lib/labs/reference-range.ts index 8d7403091..45c23e655 100644 --- a/src/lib/labs/reference-range.ts +++ b/src/lib/labs/reference-range.ts @@ -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, }; } diff --git a/src/lib/labs/serialise.ts b/src/lib/labs/serialise.ts index cc73c7bec..aa68ed09a 100644 --- a/src/lib/labs/serialise.ts +++ b/src/lib/labs/serialise.ts @@ -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(), diff --git a/src/lib/mcp/rich-reads.ts b/src/lib/mcp/rich-reads.ts index a0f9efcaa..398d20559 100644 --- a/src/lib/mcp/rich-reads.ts +++ b/src/lib/mcp/rich-reads.ts @@ -1426,6 +1426,7 @@ export async function getLabHistory( sourceReferenceLow: true, sourceReferenceHigh: true, sourceReferenceText: true, + sourceUnit: true, takenAt: true, biomarkerId: true, biomarker: { diff --git a/src/lib/openapi/routes/labs.ts b/src/lib/openapi/routes/labs.ts index bedfc0d09..3fcc7e5ca 100644 --- a/src/lib/openapi/routes/labs.ts +++ b/src/lib/openapi/routes/labs.ts @@ -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 diff --git a/src/lib/openapi/routes/ocr.ts b/src/lib/openapi/routes/ocr.ts index af682c554..74d8e4abd 100644 --- a/src/lib/openapi/routes/ocr.ts +++ b/src/lib/openapi/routes/ocr.ts @@ -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 diff --git a/tests/integration/lab-source-reference-range.test.ts b/tests/integration/lab-source-reference-range.test.ts index b4aaaaad6..57e50c028 100644 --- a/tests/integration/lab-source-reference-range.test.ts +++ b/tests/integration/lab-source-reference-range.test.ts @@ -225,6 +225,78 @@ describe("a printed reference range, end to end", () => { }); }); + it("keeps a converted reading's printed text as evidence, never as the window of the converted value", async () => { + const prisma = getPrismaClient(); + // A glucose marker tracked in mg/dL; the report prints mmol/L. + const marker = await prisma.biomarker.create({ + data: { + userId: USER_ID, + name: "Glucose", + unit: "mg/dL", + lowerBound: 70, + upperBound: 100, + }, + }); + const PRINTED_MMOL = "3,9 - 5,5 mmol/l"; + const { POST } = await import("@/app/api/labs/route"); + const res = await POST( + postReq({ + biomarkerId: marker.id, + value: 5.6, + unit: "mmol/L", + sourceReferenceLow: 3.9, + sourceReferenceHigh: 5.5, + sourceReferenceText: PRINTED_MMOL, + takenAt: "2026-07-03T08:00:00.000Z", + }), + ); + expect(res.status).toBe(201); + + // The record keeps what the report printed, verbatim. + const row = await prisma.labResult.findFirstOrThrow({ + where: { userId: USER_ID }, + }); + expect(row.sourceReferenceText).toBe(PRINTED_MMOL); + expect(row.sourceUnit).toBe("mmol/L"); + + // The DTO states the window in force in mg/dL only, and hands the printed + // text over separately, beside the unit it is in. + const dto = await readDto("Glucose"); + expect(dto.converted).toBe(true); + expect(dto.unit).toBe("mg/dL"); + expect(dto.referenceOrigin).toBe("source"); + expect(dto.referenceLow).toBeCloseTo(3.9 * 18.0182, 6); + expect(dto.referenceHigh).toBeCloseTo(5.5 * 18.0182, 6); + expect(dto.sourceReferenceText).toBeNull(); + expect(dto.printedReferenceText).toBe(PRINTED_MMOL); + expect(dto.sourceUnit).toBe("mmol/L"); + + // The doctor report has no mmol/L text to print beside mg/dL numbers. + const { loadLabResults } = + await import("@/lib/doctor-report/clinical-records"); + const labs = await loadLabResults( + USER_ID, + new Date("2026-01-01T00:00:00.000Z"), + new Date("2026-12-31T23:59:59.000Z"), + ); + expect(labs?.[0].sourceReferenceText).toBeNull(); + expect(labs?.[0].referenceLow).toBeCloseTo(3.9 * 18.0182, 6); + + // FHIR: the text, when present, must agree with low and high. Here it is + // left out, and the bounds carry the marker's unit like the value. + const { labObservations } = await import("@/lib/fhir/resources/labs"); + let seq = 0; + const observations = labObservations( + { labResults: labs } as Parameters[0], + () => `obs-${++seq}`, + ); + const range = observations[0].referenceRange?.[0]; + expect(range?.text).toBeUndefined(); + expect(range?.low?.unit).toBe("mg/dL"); + expect(range?.high?.value).toBeCloseTo(5.5 * 18.0182, 6); + expect(observations[0].valueQuantity?.unit).toBe("mg/dL"); + }); + it("keeps a window it cannot read as text and leaves the catalog in force", async () => { const prisma = getPrismaClient(); await seedMarker();