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();