From c7b3f6e4d0db8bfcd50f17cfa3dd01ff768c7495 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 7 Nov 2026 01:04:26 +0100 Subject: [PATCH] fix(labs): drop a unitless text range when a marker adopts a real unit A marker minted from a text result that stated no unit kept the printed numbers as its range, and the first numeric reading then gave the marker its unit without touching them, so a "30 - 100" printed in ng/mL became a window in nmol/L. Numbers stated without a unit are dimensionless: they stay when the adopted unit is the unity "1" and otherwise give way to the window the first number printed in its own unit, or to none. --- src/lib/labs/biomarker-store.ts | 18 +++-- src/lib/labs/minted-bounds.ts | 17 +++++ src/lib/labs/reading-conversion.ts | 29 ++++++- .../lab-text-marker-keeps-range.test.ts | 76 +++++++++++++++++++ 4 files changed, 131 insertions(+), 9 deletions(-) create mode 100644 src/lib/labs/minted-bounds.ts diff --git a/src/lib/labs/biomarker-store.ts b/src/lib/labs/biomarker-store.ts index 560f628c5..3f4728eba 100644 --- a/src/lib/labs/biomarker-store.ts +++ b/src/lib/labs/biomarker-store.ts @@ -21,6 +21,7 @@ import { type IndexedMarker, type MarkerIndex, } from "@/lib/labs/biomarker-resolve"; +import { mintedBounds } from "@/lib/labs/minted-bounds"; import { getEvent } from "@/lib/logging/context"; /** Encrypt a UTF-8 context note into the `Bytes` payload the schema stores. */ @@ -89,7 +90,10 @@ export interface MintedBiomarker { * bounds are the report's printed window whether the reading is a number or a * text result ("SEE NOTE" beside "10 - 20"): the marker outlives the reading, * and its next number is judged against them. A window that cannot be true - * (low above high) is not minted; nothing says which of the two is wrong. The + * (low above high) is not minted; nothing says which of the two is wrong. A + * text result that states no unit mints a marker with none, and its range is + * then a unitless one: the first number keeps it only under the unity "1" + * (`adoptFirstNumericUnit` in `./reading-conversion.ts`). The * `@@unique([userId, name])` index is the structural backstop; a concurrent * double-write converges via the catch-and-refetch. * @@ -113,18 +117,18 @@ export async function resolveOrMintBiomarker( const hit = resolveInIndex(index, name, input.unit || null); if (hit) return toMinted(hit.marker); - const transposed = - input.referenceLow !== null && - input.referenceHigh !== null && - input.referenceLow > input.referenceHigh; + const { lowerBound, upperBound } = mintedBounds( + input.referenceLow, + input.referenceHigh, + ); try { const created = await prisma.biomarker.create({ data: { userId, name, unit: input.unit, - lowerBound: transposed ? null : input.referenceLow, - upperBound: transposed ? null : input.referenceHigh, + lowerBound, + upperBound, panel: input.panel, analyteKey: matchAnalyteKey({ name, unit: input.unit }), }, diff --git a/src/lib/labs/minted-bounds.ts b/src/lib/labs/minted-bounds.ts new file mode 100644 index 000000000..1db06511d --- /dev/null +++ b/src/lib/labs/minted-bounds.ts @@ -0,0 +1,17 @@ +/** + * The range a new marker is minted with: the printed window, unless it cannot + * be true (low above high), when nothing says which of the two is wrong and + * neither is kept. Every place that mints a marker, or pretends to for an + * import preview, takes its bounds from here. + * + * Pure and dependency-free. + */ +export function mintedBounds( + low: number | null, + high: number | null, +): { lowerBound: number | null; upperBound: number | null } { + if (low !== null && high !== null && low > high) { + return { lowerBound: null, upperBound: null }; + } + return { lowerBound: low, upperBound: high }; +} diff --git a/src/lib/labs/reading-conversion.ts b/src/lib/labs/reading-conversion.ts index f52f5e046..2bd59fa2a 100644 --- a/src/lib/labs/reading-conversion.ts +++ b/src/lib/labs/reading-conversion.ts @@ -16,6 +16,8 @@ import type { PrismaClient } from "@/generated/prisma/client"; import { ensureAnalyteKey } from "./analyte-key-store"; +import { mintedBounds } from "./minted-bounds"; +import { isDimensionlessUnit } from "./dimensionless"; import { convertLabRange, convertLabValue, @@ -28,6 +30,9 @@ export interface ReadingMarker { name: string; unit: string; analyteKey?: string | null; + /** Set on the caller's object when the first numeric unit replaces them. */ + lowerBound?: number | null; + upperBound?: number | null; } export type ReadingInMarkerUnit = @@ -66,7 +71,10 @@ export async function readingInMarkerUnit( input; if ( unit !== undefined && - (await adoptFirstNumericUnit(client, marker, unit)) + (await adoptFirstNumericUnit(client, marker, unit, { + low: sourceReferenceLow, + high: sourceReferenceHigh, + })) ) { return { ok: true, @@ -128,11 +136,21 @@ export async function readingInMarkerUnit( * caller's object, which writes the reading under it. The adoption is not in * the reading's transaction: if the write fails after it, the marker keeps * the unit the reading stated, which is the unit its next reading will state. + * + * The range the marker holds came with no unit: the text result it was minted + * from stated none. Numbers stated without a unit are dimensionless, so they + * stay only when the adopted unit is the unity ("1"), the ratio a text result + * beside "10 - 20" usually is. Under any other unit they are not a window + * (a "30 - 100" printed in ng/mL is not one in nmol/L, and there is no unit + * to convert from), so they give way to the window this reading printed in + * the unit it states, or to none, the way a marker this reading had minted + * would have started. */ async function adoptFirstNumericUnit( client: Pick, marker: ReadingMarker, unit: string, + printed: { low: number | null; high: number | null }, ): Promise { const stated = unit.trim(); if (marker.unit.trim() !== "" || stated === "") return false; @@ -140,9 +158,12 @@ async function adoptFirstNumericUnit( where: { biomarkerId: marker.id, value: { not: null }, deletedAt: null }, }); if (numeric > 0) return false; + const bounds = isDimensionlessUnit(stated) + ? null + : mintedBounds(printed.low, printed.high); const { count } = await client.biomarker.updateMany({ where: { id: marker.id, unit: "" }, - data: { unit: stated }, + data: { unit: stated, ...bounds }, }); if (count === 0) { // Another reading set the unit first: carry on with the one it chose, @@ -155,5 +176,9 @@ async function adoptFirstNumericUnit( return false; } marker.unit = stated; + if (bounds) { + marker.lowerBound = bounds.lowerBound; + marker.upperBound = bounds.upperBound; + } return true; } diff --git a/tests/integration/lab-text-marker-keeps-range.test.ts b/tests/integration/lab-text-marker-keeps-range.test.ts index 123159d18..edf95dbcb 100644 --- a/tests/integration/lab-text-marker-keeps-range.test.ts +++ b/tests/integration/lab-text-marker-keeps-range.test.ts @@ -289,6 +289,82 @@ describe("a free-text reading that is a text result", () => { }); }); +describe("a text marker's range and the first unit it adopts", () => { + it("drops a range stated without a unit when a real unit arrives", async () => { + // The report printed "30 - 100" beside a pending result, in ng/mL, and + // said so nowhere: the marker holds the numbers with no unit. + await postLab({ + analyte: "Vitamin D", + valueText: "PENDING", + referenceLow: 30, + referenceHigh: 100, + }); + const { id } = await bounds("Vitamin D"); + // The next report states nmol/L. 30 - 100 is not a window in nmol/L. + await postLab({ + biomarkerId: id, + value: 60, + unit: "nmol/L", + takenAt: "2026-10-01T12:00:00.000Z", + }); + const marker = await prisma.biomarker.findUniqueOrThrow({ where: { id } }); + expect(marker).toMatchObject({ + unit: "nmol/L", + lowerBound: null, + upperBound: null, + }); + const reading = await prisma.labResult.findFirstOrThrow({ + where: { biomarkerId: id, value: { not: null } }, + }); + expect(reading).toMatchObject({ + unit: "nmol/L", + referenceLow: null, + referenceHigh: null, + }); + }); + + it("takes the window the first number printed in the unit it adopts", async () => { + await postLab({ + analyte: "Vitamin D", + valueText: "PENDING", + referenceLow: 30, + referenceHigh: 100, + }); + const { id } = await bounds("Vitamin D"); + await postLab({ + biomarkerId: id, + value: 60, + unit: "nmol/L", + sourceReferenceLow: 75, + sourceReferenceHigh: 250, + takenAt: "2026-10-01T12:00:00.000Z", + }); + expect(await bounds("Vitamin D")).toMatchObject({ low: 75, high: 250 }); + }); + + it("keeps a unitless range when the unit adopted is the unity", async () => { + await postLab({ + analyte: "BUN/Creatinine ratio", + valueText: "SEE NOTE", + referenceLow: 10, + referenceHigh: 20, + }); + const { id } = await bounds("BUN/Creatinine ratio"); + await postLab({ + biomarkerId: id, + value: 18, + unit: "1", + sourceReferenceLow: 5, + sourceReferenceHigh: 30, + takenAt: "2026-10-01T12:00:00.000Z", + }); + expect(await bounds("BUN/Creatinine ratio")).toMatchObject({ + low: 10, + high: 20, + }); + }); +}); + // --- CSV import ------------------------------------------------------------- describe("a CSV text result", () => {