From 503f0f183a5a298dd1b4ce4064b444a55d8ea200 Mon Sep 17 00:00:00 2001 From: BernhardBuckel <33808384+BernhardBuckel@users.noreply.github.com> Date: Sun, 4 Oct 2026 11:05:36 +0200 Subject: [PATCH 01/12] feat(labs): add the lab unit conversion registry and its pure functions Phase 2 of #1095: new code only, no behaviour change. Nothing imports it yet. An analyte lists its units as affine maps to a hub unit (hub = a * value + b), so any pair goes unit -> hub -> unit. Molar-mass factors are derived from the formula and a cited atomic-weight table instead of being typed; glucose reuses MGDL_PER_MMOL. HbA1c is the IFCC-NGSP master equation. Units that differ only by a power of ten convert for any analyte, inside one family and never across mass and amount. An entry with no source, a hub that is not one of its units, or a non-positive factor makes the registry fail to load. convertLabValue, convertLabRange and conversionsFor refuse rather than guess: unknown unit, unknown analyte, a mass to amount pair without an analyte key, a value that is not finite. Triglycerides, haemoglobin in mmol/L, ferritin and B12 in pmol/L and enzymes in ukat/L stay refusals. Refs #1095 --- .../unit-conversion/__tests__/convert.test.ts | 380 ++++++++++++++++++ .../__tests__/registry.test.ts | 191 +++++++++ .../unit-conversion/__tests__/scale.test.ts | 89 ++++ .../labs/unit-conversion/atomic-weights.ts | 50 +++ src/lib/labs/unit-conversion/convert.ts | 167 ++++++++ src/lib/labs/unit-conversion/registry.ts | 231 +++++++++++ src/lib/labs/unit-conversion/scale.ts | 130 ++++++ 7 files changed, 1238 insertions(+) create mode 100644 src/lib/labs/unit-conversion/__tests__/convert.test.ts create mode 100644 src/lib/labs/unit-conversion/__tests__/registry.test.ts create mode 100644 src/lib/labs/unit-conversion/__tests__/scale.test.ts create mode 100644 src/lib/labs/unit-conversion/atomic-weights.ts create mode 100644 src/lib/labs/unit-conversion/convert.ts create mode 100644 src/lib/labs/unit-conversion/registry.ts create mode 100644 src/lib/labs/unit-conversion/scale.ts diff --git a/src/lib/labs/unit-conversion/__tests__/convert.test.ts b/src/lib/labs/unit-conversion/__tests__/convert.test.ts new file mode 100644 index 000000000..762b60ba5 --- /dev/null +++ b/src/lib/labs/unit-conversion/__tests__/convert.test.ts @@ -0,0 +1,380 @@ +import { describe, expect, it } from "vitest"; + +import { allAnalyteConversions } from "../registry"; +import { + convertLabRange, + convertLabValue, + conversionsFor, + type ConvertedValue, +} from "../convert"; + +const ok = (result: ConvertedValue) => { + if (!result.ok) throw new Error(`refused: ${result.reason}`); + return result; +}; + +const convert = ( + analyteKey: string | null | undefined, + value: number, + from: string, + to: string, +) => convertLabValue({ analyteKey, value, from, to }); + +describe("reference pairs, checked by hand", () => { + it("5.6 mmol/L glucose is 100.9 mg/dL", () => { + const r = ok(convert("fasting-glucose", 5.6, "mmol/L", "mg/dL")); + expect(r.value).toBeCloseTo(100.9, 1); + expect(r.via).toBe("analyte"); + }); + + it("1.0 mg/dL creatinine is 88.4 µmol/L", () => { + expect(ok(convert("creatinine", 1, "mg/dL", "µmol/L")).value).toBeCloseTo( + 88.4, + 1, + ); + }); + + it("6.5 % HbA1c is 47.5 mmol/mol, and back", () => { + const ifcc = ok(convert("hba1c", 6.5, "%", "mmol/mol")).value; + expect(ifcc).toBeCloseTo(47.5, 1); + expect(ok(convert("hba1c", ifcc, "mmol/mol", "%")).value).toBeCloseTo( + 6.5, + 9, + ); + }); + + it("30 ng/mL vitamin D is 74.9 nmol/L", () => { + expect(ok(convert("vitamin-d", 30, "ng/mL", "nmol/L")).value).toBeCloseTo( + 74.9, + 1, + ); + }); + + it("5.2 mmol/L total cholesterol is 201 mg/dL, and LDL goes the same way", () => { + expect( + ok(convert("total-cholesterol", 5.2, "mmol/L", "mg/dL")).value, + ).toBeCloseTo(201.1, 1); + expect(ok(convert("ldl", 3, "mmol/L", "mg/dL")).value).toBeCloseTo(116, 0); + }); + + it("5 mmol/L urea is 30.0 mg/dL, and 5 mmol/L is 14.0 mg/dL as BUN", () => { + expect(ok(convert("urea", 5, "mmol/L", "mg/dL")).value).toBeCloseTo( + 30.03, + 2, + ); + expect( + ok(convert("blood-urea-nitrogen", 5, "mmol/L", "mg/dL")).value, + ).toBeCloseTo(14.007, 2); + }); + + it("2.4 mmol/L total calcium is 9.6 mg/dL", () => { + expect( + ok(convert("calcium-total", 2.4, "mmol/L", "mg/dL")).value, + ).toBeCloseTo(9.62, 1); + }); + + it("300 µmol/L uric acid is 5.0 mg/dL and 17.1 µmol/L bilirubin is 1.0 mg/dL", () => { + expect(ok(convert("uric-acid", 300, "µmol/L", "mg/dL")).value).toBeCloseTo( + 5.04, + 2, + ); + expect( + ok(convert("bilirubin-total", 17.1, "µmol/L", "mg/dL")).value, + ).toBeCloseTo(1.0, 2); + }); +}); + +describe("the same unit written two ways", () => { + it("returns the value untouched", () => { + expect(ok(convert(null, 5.6, "mmol/l", "mmol/L")).value).toBe(5.6); + expect(ok(convert(null, 5.6, "MG/DL", "mg/dl")).value).toBe(5.6); + expect(ok(convert("fasting-glucose", 5.6, "mg/dl", "mg/dL")).value).toBe( + 5.6, + ); + }); + + it("reads a spelling of the unit through the normaliser", () => { + const a = ok(convert("creatinine", 88.4, "umol/l", "mg/dl")); + const b = ok(convert("creatinine", 88.4, "µmol/L", "mg/dL")); + expect(a.value).toBe(b.value); + }); +}); + +describe("a pair of scale units needs no analyte", () => { + it.each([ + [1, "g/L", "mg/L", 1000], + [1, "mg/dL", "mg/L", 10], + [1, "g/dL", "g/L", 10], + [1, "ng/mL", "µg/L", 1], + [1, "ng/mL", "pg/mL", 1000], + [1, "ng/dL", "ng/L", 10], + [1, "mmol/L", "µmol/L", 1000], + [1, "mol/L", "pmol/L", 1e12], + [1, "mIU/L", "µIU/mL", 1], + [1, "IU/L", "mIU/L", 1000], + [4.5, "10³/µL", "10⁹/L", 4.5], + [1, "10⁶/µL", "10¹²/L", 1], + [1, "10⁶/µL", "10³/µL", 1000], + [1, "G/L", "10³/µL", 1], + ])("%s %s is %s %s", (value, from, to, expected) => { + const r = ok(convert(null, value, from, to)); + expect(r.via).toBe("scale"); + expect(r.value).toBeCloseTo(expected, 9); + }); + + it("is exact where a power of ten is exactly representable", () => { + expect(ok(convert(null, 5, "g/L", "mg/L")).value).toBe(5000); + expect(ok(convert(null, 5, "mg/L", "g/L")).value).toBe(0.005); + }); + + it("works for an analyte's own mass units too: glucose in g/L", () => { + expect(ok(convert("fasting-glucose", 1, "g/L", "mg/dL")).value).toBe(100); + }); +}); + +describe("an analyte's units reach each other across a scale", () => { + it("glucose in g/L to mmol/L", () => { + // 1 g/L = 100 mg/dL = 100 / 18.0182 mmol/L + expect( + ok(convert("fasting-glucose", 1, "g/L", "mmol/L")).value, + ).toBeCloseTo(100 / 18.0182, 9); + }); + + it("creatinine in mmol/L to mg/dL (the entry lists µmol/L)", () => { + expect( + ok(convert("creatinine", 0.0884, "mmol/L", "mg/dL")).value, + ).toBeCloseTo(1.0, 3); + }); + + it("vitamin D in µg/L to nmol/L (the entry lists ng/mL)", () => { + expect(ok(convert("vitamin-d", 30, "µg/L", "nmol/L")).value).toBeCloseTo( + 74.9, + 1, + ); + }); +}); + +describe("a round trip returns the start, for every unit of every analyte", () => { + for (const entry of allAnalyteConversions()) { + const units = Object.keys(entry.units); + for (const from of units) { + for (const to of units) { + it(`${entry.key}: ${from} -> ${to} -> ${from}`, () => { + for (const start of [0.5, 1, 7.3, 120.45]) { + const there = ok(convert(entry.key, start, from, to)).value; + const back = ok(convert(entry.key, there, to, from)).value; + expect(Math.abs(back - start) / start).toBeLessThan(1e-12); + } + }); + } + } + } + + it("holds for the scale pairs", () => { + const units = ["g/L", "mg/dL", "µg/L", "ng/mL", "pg/mL", "ng/dL", "g/dL"]; + for (const from of units) { + for (const to of units) { + const there = ok(convert(null, 7.3, from, to)).value; + const back = ok(convert(null, there, to, from)).value; + expect(Math.abs(back - 7.3) / 7.3).toBeLessThan(1e-12); + } + } + }); +}); + +describe("a refusal, never a guess", () => { + it("refuses a unit nobody knows", () => { + expect(convert("fasting-glucose", 5, "mmol/L", "furlongs")).toEqual({ + ok: false, + reason: "unknown_unit", + }); + expect(convert(null, 5, "foo", "bar")).toEqual({ + ok: false, + reason: "unknown_unit", + }); + }); + + it("refuses an analyte it has no entry for", () => { + expect(convert("sodium", 140, "mmol/L", "mg/dL")).toEqual({ + ok: false, + reason: "unknown_analyte", + }); + expect(convert("triglycerides", 1.7, "mmol/L", "mg/dL")).toEqual({ + ok: false, + reason: "unknown_analyte", + }); + }); + + it("refuses mass to amount when no analyte key is given", () => { + expect(convert(null, 100, "mg/dL", "mmol/L")).toEqual({ + ok: false, + reason: "unknown_analyte", + }); + expect(convert(undefined, 100, "mg/dL", "mmol/L")).toEqual({ + ok: false, + reason: "unknown_analyte", + }); + }); + + it("refuses a unit the entry does not reach", () => { + // HbA1c knows % and mmol/mol; g/L is neither, and not a scale of either. + expect(convert("hba1c", 6.5, "%", "g/L")).toEqual({ + ok: false, + reason: "unknown_unit", + }); + }); + + it.each([Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY])( + "refuses %s", + (value) => { + expect(convert("fasting-glucose", value, "mmol/L", "mg/dL")).toEqual({ + ok: false, + reason: "not_finite", + }); + expect(convert(null, value, "g/L", "mg/L")).toEqual({ + ok: false, + reason: "not_finite", + }); + }, + ); + + it("never turns the lookalike pairs into a conversion", () => { + // Different measurements, not different units of one. + expect(convert("urea", 5, "mmol/L", "mg/dL").ok).toBe(true); + expect(convert("calcium-total", 2.4, "mmol/L", "mg/dL").ok).toBe(true); + expect(convert("calcium-ionised", 1.2, "mmol/L", "mg/dL").ok).toBe(false); + }); +}); + +describe("a reference range", () => { + it("converts both bounds together and keeps them in order", () => { + const r = convertLabRange({ + analyteKey: "fasting-glucose", + low: 3.9, + high: 5.6, + from: "mmol/L", + to: "mg/dL", + }); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.low).toBeCloseTo(70.3, 1); + expect(r.high).toBeCloseTo(100.9, 1); + expect(r.low!).toBeLessThan(r.high!); + expect(r.via).toBe("analyte"); + }); + + it("keeps the order for HbA1c, whose map has an offset", () => { + const r = convertLabRange({ + analyteKey: "hba1c", + low: 4, + high: 5.6, + from: "%", + to: "mmol/mol", + }); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.low!).toBeLessThan(r.high!); + expect(r.low).toBeCloseTo(20.2, 1); + expect(r.high).toBeCloseTo(37.7, 1); + }); + + it("leaves an open bound open", () => { + expect( + convertLabRange({ + analyteKey: "fasting-glucose", + low: null, + high: 5.6, + from: "mmol/L", + to: "mg/dL", + }), + ).toMatchObject({ ok: true, low: null }); + expect( + convertLabRange({ + analyteKey: "fasting-glucose", + low: 3.9, + high: null, + from: "mmol/L", + to: "mg/dL", + }), + ).toMatchObject({ ok: true, high: null }); + }); + + it("refuses the whole range when the units do not convert", () => { + expect( + convertLabRange({ + analyteKey: null, + low: 3.9, + high: 5.6, + from: "mmol/L", + to: "mg/dL", + }), + ).toEqual({ ok: false, reason: "unknown_analyte" }); + }); + + it("refuses a range of two open bounds when the units do not convert either", () => { + expect( + convertLabRange({ + analyteKey: null, + low: null, + high: null, + from: "mmol/L", + to: "mg/dL", + }), + ).toEqual({ ok: false, reason: "unknown_analyte" }); + }); + + it("is the unit spelled another way: no conversion, bounds as they are", () => { + expect( + convertLabRange({ + analyteKey: null, + low: 3.9, + high: 5.6, + from: "mmol/l", + to: "mmol/L", + }), + ).toEqual({ ok: true, low: 3.9, high: 5.6, via: "scale" }); + }); +}); + +describe("conversionsFor", () => { + it("lists what glucose in mg/dL can arrive as, without mg/dL itself", () => { + const units = conversionsFor({ + analyteKey: "fasting-glucose", + to: "mg/dL", + }); + expect(units).toContain("mmol/L"); + expect(units).toContain("g/L"); + expect(units).toContain("µmol/L"); // the amount family, scaled + expect(units).not.toContain("mg/dL"); + expect(units).not.toContain("mg/dl"); + }); + + it("lists only the scale family for a marker with no entry", () => { + const units = conversionsFor({ analyteKey: null, to: "mg/dL" }); + expect(units).toContain("g/L"); + expect(units).not.toContain("mmol/L"); + }); + + it("lists nothing for a unit that has no family and no entry", () => { + expect(conversionsFor({ analyteKey: null, to: "%" })).toEqual([]); + }); + + it("lists HbA1c's two units", () => { + expect(conversionsFor({ analyteKey: "hba1c", to: "%" })).toEqual([ + "mmol/mol", + ]); + }); + + it("every listed unit really converts", () => { + for (const entry of allAnalyteConversions()) { + for (const to of Object.keys(entry.units)) { + for (const from of conversionsFor({ analyteKey: entry.key, to })) { + expect( + convert(entry.key, 1, from, to).ok, + `${entry.key}: ${from} -> ${to}`, + ).toBe(true); + } + } + } + }); +}); diff --git a/src/lib/labs/unit-conversion/__tests__/registry.test.ts b/src/lib/labs/unit-conversion/__tests__/registry.test.ts new file mode 100644 index 000000000..724a7f8b4 --- /dev/null +++ b/src/lib/labs/unit-conversion/__tests__/registry.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it } from "vitest"; + +import { MGDL_PER_MMOL } from "@/lib/glucose"; + +import { molarMass } from "../atomic-weights"; +import { + allAnalyteConversions, + analyteConversionFor, + defineAnalyteConversion, + type AnalyteConversion, +} from "../registry"; + +/** + * The guard over the registry. Every entry is checked for what makes a + * conversion safe to ship: a named source, a hub that is one of its units, a + * finite positive slope, a key nobody else claims, and, for the molar-mass + * entries, a factor that equals one recomputed here from the molar mass written + * out by hand, not by the code that built the entry. + */ + +/** g/mol, summed by hand from H 1.008, C 12.011, N 14.007, O 15.999, Ca 40.078. */ +const MOLAR_MASS: Record = { + "fasting-glucose": 180.156, // C6H12O6 + "total-cholesterol": 386.664, // C27H46O + creatinine: 113.12, // C4H7N3O + urea: 60.056, // CH4N2O + "blood-urea-nitrogen": 28.014, // N2 + "uric-acid": 168.112, // C5H4N4O3 + "calcium-total": 40.078, // Ca + "bilirubin-total": 584.673, // C33H36N4O6 + "vitamin-d": 400.647, // C27H44O2 +}; + +/** Of the entry's amount unit: how many of its mass unit one of it is, per g/mol. */ +const POWER_OF_TEN: Record = { + "fasting-glucose": 1 / 10, // mmol/L -> mg/dL + "total-cholesterol": 1 / 10, + creatinine: 1 / 10_000, // µmol/L -> mg/dL + urea: 1 / 10, + "blood-urea-nitrogen": 1 / 10, + "uric-acid": 1 / 10_000, + "calcium-total": 1 / 10, + "bilirubin-total": 1 / 10_000, + "vitamin-d": 1 / 1_000, // nmol/L -> ng/mL +}; + +describe("the registry's entries", () => { + const entries = allAnalyteConversions(); + + it("holds the starter set", () => { + expect(entries.map((entry) => entry.key).sort()).toEqual( + [ + "blood-urea-nitrogen", + "bilirubin-total", + "calcium-total", + "creatinine", + "fasting-glucose", + "hba1c", + "total-cholesterol", + "uric-acid", + "urea", + "vitamin-d", + ].sort(), + ); + }); + + it.each(entries.map((entry) => [entry.key, entry] as const))( + "%s: names its source, has a hub among its units, and a finite positive slope", + (_key, entry) => { + expect(entry.source.trim().length).toBeGreaterThan(10); + expect(Object.keys(entry.units)).toContain(entry.hub); + for (const { a, b } of Object.values(entry.units)) { + expect(Number.isFinite(a) && a > 0).toBe(true); + expect(Number.isFinite(b)).toBe(true); + } + expect(entry.units[entry.hub]).toEqual({ a: 1, b: 0 }); + }, + ); + + it("never lists a key or an alias twice, across entries", () => { + const keys = entries.flatMap((entry) => [entry.key, ...entry.aliases]); + expect(new Set(keys).size).toBe(keys.length); + }); + + it("finds an analyte by its alias: LDL and HDL are cholesterol", () => { + expect(analyteConversionFor("ldl")?.key).toBe("total-cholesterol"); + expect(analyteConversionFor("hdl")?.key).toBe("total-cholesterol"); + expect(analyteConversionFor("nope")).toBeNull(); + expect(analyteConversionFor(null)).toBeNull(); + }); + + it("keeps urea and blood urea nitrogen, and the two calciums, apart", () => { + expect(analyteConversionFor("urea")).not.toBe( + analyteConversionFor("blood-urea-nitrogen"), + ); + expect(analyteConversionFor("calcium-ionised")).toBeNull(); + }); + + describe("the molar-mass entries", () => { + const molar = entries.filter((entry) => entry.kind === "molar-mass"); + + it("are exactly the ones this table knows", () => { + expect(molar.map((entry) => entry.key).sort()).toEqual( + Object.keys(MOLAR_MASS).sort(), + ); + }); + + it.each(molar.map((entry) => [entry.key, entry] as const))( + "%s: the factor is the molar mass moved by its powers of ten", + (key, entry) => { + const [amountUnit] = Object.keys(entry.units).filter( + (unit) => unit !== entry.hub, + ); + const { a } = entry.units[amountUnit]; + const expected = MOLAR_MASS[key] * POWER_OF_TEN[key]; + if (key === "fasting-glucose") { + // The constant the rest of the app uses. + expect(a).toBe(MGDL_PER_MMOL); + } else { + expect(a).toBeCloseTo(expected, 9); + } + }, + ); + + it("has glucose agree with the derived factor to within 0.02 %", () => { + const derived = molarMass({ C: 6, H: 12, O: 6 }) / 10; + expect(derived).toBeCloseTo(18.0156, 4); + expect(Math.abs(MGDL_PER_MMOL - derived) / derived).toBeLessThan(0.0002); + }); + }); + + it("has HbA1c as the IFCC-NGSP master equation, affine and not a factor", () => { + const hba1c = analyteConversionFor("hba1c")!; + expect(hba1c.kind).toBe("standard"); + expect(hba1c.units["mmol/mol"]).toEqual({ a: 0.09148, b: 2.152 }); + expect(hba1c.source).toContain("Hoelzel"); + }); + + it("has no convention-based entry until its source is cited (triglycerides)", () => { + expect(entries.filter((entry) => entry.kind === "convention")).toEqual([]); + expect(analyteConversionFor("triglycerides")).toBeNull(); + }); +}); + +describe("an entry that is not safe cannot be defined", () => { + const valid: AnalyteConversion = { + key: "x", + aliases: [], + hub: "mg/dL", + units: { "mg/dL": { a: 1, b: 0 }, "mmol/L": { a: 10, b: 0 } }, + kind: "molar-mass", + source: "A cited source.", + }; + + it("accepts a valid entry", () => { + expect(defineAnalyteConversion(valid).key).toBe("x"); + }); + + it("refuses an entry with no source", () => { + expect(() => defineAnalyteConversion({ ...valid, source: " " })).toThrow( + /no source/, + ); + }); + + it("refuses a hub that is not one of its units", () => { + expect(() => defineAnalyteConversion({ ...valid, hub: "g/L" })).toThrow( + /hub/, + ); + }); + + it.each([0, -1, Number.NaN, Number.POSITIVE_INFINITY])( + "refuses a slope of %s", + (a) => { + expect(() => + defineAnalyteConversion({ + ...valid, + units: { ...valid.units, "mmol/L": { a, b: 0 } }, + }), + ).toThrow(/finite positive/); + }, + ); + + it("refuses a unit that is not in its normalised spelling", () => { + expect(() => + defineAnalyteConversion({ + ...valid, + units: { ...valid.units, "mmol/l": { a: 10, b: 0 } }, + }), + ).toThrow(/normalised/); + }); +}); diff --git a/src/lib/labs/unit-conversion/__tests__/scale.test.ts b/src/lib/labs/unit-conversion/__tests__/scale.test.ts new file mode 100644 index 000000000..8fe7e1ac9 --- /dev/null +++ b/src/lib/labs/unit-conversion/__tests__/scale.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; + +import { molarMass, formulaLabel } from "../atomic-weights"; +import { + convertByScale, + scaleUnitOf, + scaleUnitsInFamilyOf, + shiftPowerOfTen, +} from "../scale"; + +describe("scale families", () => { + it("puts each unit in the family of its quantity", () => { + expect(scaleUnitOf("mg/dL")?.family).toBe("mass"); + expect(scaleUnitOf("pmol/L")?.family).toBe("amount"); + expect(scaleUnitOf("mIU/L")?.family).toBe("activity"); + expect(scaleUnitOf("10³/µL")?.family).toBe("count"); + }); + + it("derives the exponent from the prefix and the volume", () => { + // Relative to g/L. + expect(scaleUnitOf("g/L")?.exponent).toBe(0); + expect(scaleUnitOf("g/dL")?.exponent).toBe(1); + expect(scaleUnitOf("mg/dL")?.exponent).toBe(-2); + expect(scaleUnitOf("ng/mL")?.exponent).toBe(-6); + expect(scaleUnitOf("ng/dL")?.exponent).toBe(-8); + expect(scaleUnitOf("pg/mL")?.exponent).toBe(-9); + }); + + it("knows a unit by any spelling the normaliser resolves", () => { + expect(scaleUnitOf("mg/dl")).toEqual(scaleUnitOf("mg/dL")); + expect(scaleUnitOf("ug/L")).toEqual(scaleUnitOf("µg/L")); + expect(scaleUnitOf("umol/l")).toEqual(scaleUnitOf("µmol/L")); + }); + + it("knows no family for a unit that is not a plain scale", () => { + expect(scaleUnitOf("%")).toBeNull(); + expect(scaleUnitOf("mmol/mol")).toBeNull(); + expect(scaleUnitOf("furlongs")).toBeNull(); + // A lone capital M is mega, not milli: not read as a scale unit at all. + expect(scaleUnitOf("MIU/L")).toBeNull(); + }); + + it("does not make mass to amount a scale pair", () => { + expect(convertByScale(1, "mg/dL", "mmol/L")).toBeNull(); + expect(convertByScale(1, "g/L", "mol/L")).toBeNull(); + }); + + it("keeps the 10^9/L of a blood count apart from g/L", () => { + expect(scaleUnitOf("G/L")?.family).toBe("count"); + expect(scaleUnitOf("g/L")?.family).toBe("mass"); + expect(convertByScale(1, "G/L", "g/L")).toBeNull(); + }); + + it("lists every unit of a family, in its own spelling", () => { + const amount = scaleUnitsInFamilyOf("mmol/L"); + expect(amount).toEqual( + expect.arrayContaining(["mol/L", "mmol/L", "µmol/L", "nmol/L", "pmol/L"]), + ); + expect(amount).not.toContain("mg/dL"); + expect(scaleUnitsInFamilyOf("%")).toEqual([]); + }); +}); + +describe("shiftPowerOfTen", () => { + it("multiplies for a positive power and divides for a negative one", () => { + expect(shiftPowerOfTen(5, 3)).toBe(5000); + expect(shiftPowerOfTen(5, -3)).toBe(0.005); + expect(shiftPowerOfTen(5, 0)).toBe(5); + }); + + it("is exact where multiplying by 10^-n is not", () => { + // 3 * 1e-3 is 0.003 with a stray last digit; 3 / 1000 is exact. + expect(shiftPowerOfTen(3, -3)).toBe(0.003); + }); +}); + +describe("atomic weights", () => { + it("sums a molar mass from the table, without float noise", () => { + expect(molarMass({ C: 6, H: 12, O: 6 })).toBe(180.156); + expect(molarMass({ Ca: 1 })).toBe(40.078); + expect(molarMass({ N: 2 })).toBe(28.014); + }); + + it("writes a formula in a stable order", () => { + expect(formulaLabel({ O: 6, H: 12, C: 6 })).toBe("C6H12O6"); + expect(formulaLabel({ Ca: 1 })).toBe("Ca"); + expect(formulaLabel({ C: 1, H: 4, N: 2, O: 1 })).toBe("CH4N2O"); + }); +}); diff --git a/src/lib/labs/unit-conversion/atomic-weights.ts b/src/lib/labs/unit-conversion/atomic-weights.ts new file mode 100644 index 000000000..9c0fe807d --- /dev/null +++ b/src/lib/labs/unit-conversion/atomic-weights.ts @@ -0,0 +1,50 @@ +/** + * The atomic weights the molar-mass conversions are derived from. + * + * Conventional atomic weights as published by the IUPAC Commission on Isotopic + * Abundances and Atomic Weights (CIAAW), "Standard atomic weights". Only the + * elements a conversion in the registry needs are listed; one more element is + * one more line here, with a conversion that uses it. + * + * A molar mass is summed from these, never typed, so a long decimal cannot be + * copied wrong into a factor that then touches every reading of a marker. + */ +export const ATOMIC_WEIGHTS_SOURCE = + "IUPAC CIAAW, Standard atomic weights (conventional values)"; + +export const ATOMIC_WEIGHTS = { + H: 1.008, + C: 12.011, + N: 14.007, + O: 15.999, + Ca: 40.078, +} as const; + +export type Element = keyof typeof ATOMIC_WEIGHTS; + +/** Atoms per molecule, by element: urea is `{ C: 1, H: 4, N: 2, O: 1 }`. */ +export type Formula = Readonly>>; + +/** g/mol of a formula, summed from `ATOMIC_WEIGHTS`. */ +export function molarMass(formula: Formula): number { + let total = 0; + for (const [element, count] of Object.entries(formula) as Array< + [Element, number] + >) { + total += ATOMIC_WEIGHTS[element] * count; + } + // Summing decimals leaves float noise past the sixth place (180.15600000000001). + return Math.round(total * 1e6) / 1e6; +} + +/** `C6H12O6` for `{ C: 6, H: 12, O: 6 }`: for a citation, in a stable order. */ +export function formulaLabel(formula: Formula): string { + const order: Element[] = ["C", "H", "N", "O", "Ca"]; + return order + .filter((element) => (formula[element] ?? 0) > 0) + .map((element) => { + const count = formula[element]!; + return count === 1 ? element : `${element}${count}`; + }) + .join(""); +} diff --git a/src/lib/labs/unit-conversion/convert.ts b/src/lib/labs/unit-conversion/convert.ts new file mode 100644 index 000000000..9e108489b --- /dev/null +++ b/src/lib/labs/unit-conversion/convert.ts @@ -0,0 +1,167 @@ +/** + * Convert a lab value, or a reference range, between units. Pure, no I/O. + * + * The rules, in order: + * + * 1. The same unit written two ways (`mmol/l`, `mmol/L`) is the same unit and + * the value is returned as it is. + * 2. Two units in one scale family (`mg/L`, `g/dL`) convert by their power of + * ten, for any analyte, with no key. This is `via: "scale"`. + * 3. Anything else needs the analyte's entry in the registry (`via: + * "analyte"`): each unit is moved to a unit the entry lists by scale where + * needed, then unit -> hub -> unit. + * + * Everything else is a refusal, never a guess: an unknown unit, an analyte with + * no entry, a value that is not finite. A mass to amount pair asked for without + * an analyte key is an `unknown_analyte` refusal: it is never a scale pair. + */ +import { normaliseLabUnit } from "../unit-normalise"; +import { + analyteConversionFor, + type Affine, + type AnalyteConversion, +} from "./registry"; +import { scaleUnitOf, scaleUnitsInFamilyOf, shiftPowerOfTen } from "./scale"; + +export type ConversionRefusal = + "unknown_analyte" | "unknown_unit" | "not_finite"; + +export type ConvertedValue = + | { ok: true; value: number; via: "analyte" | "scale" } + | { ok: false; reason: ConversionRefusal }; + +export interface ConvertLabValueInput { + /** Catalog slug or registry key of the marker's analyte; null when it has none. */ + analyteKey: string | null | undefined; + value: number; + from: string; + to: string; +} + +/** `unit` written as one of the entry's own units, by scale where it is not one. */ +function intoEntryUnit( + entry: AnalyteConversion, + unit: string, +): { entryUnit: string; affine: Affine; shift: number } | null { + const norm = normaliseLabUnit(unit); + const direct = entry.units[norm]; + if (direct) return { entryUnit: norm, affine: direct, shift: 0 }; + const own = scaleUnitOf(norm); + if (!own) return null; + for (const [entryUnit, affine] of Object.entries(entry.units)) { + const candidate = scaleUnitOf(entryUnit); + if (candidate && candidate.family === own.family) { + return { entryUnit, affine, shift: own.exponent - candidate.exponent }; + } + } + return null; +} + +export function convertLabValue(input: ConvertLabValueInput): ConvertedValue { + const { analyteKey, value, from, to } = input; + if (!Number.isFinite(value)) return { ok: false, reason: "not_finite" }; + + const fromNorm = normaliseLabUnit(from); + const toNorm = normaliseLabUnit(to); + if (fromNorm === toNorm) return { ok: true, value, via: "scale" }; + + const a = scaleUnitOf(fromNorm); + const b = scaleUnitOf(toNorm); + if (a && b && a.family === b.family) { + return { + ok: true, + value: shiftPowerOfTen(value, a.exponent - b.exponent), + via: "scale", + }; + } + + const entry = analyteConversionFor(analyteKey); + if (!entry) { + // Both units are real but of different families: it needs an analyte. + return { + ok: false, + reason: a && b ? "unknown_analyte" : "unknown_unit", + }; + } + + const source = intoEntryUnit(entry, fromNorm); + const target = intoEntryUnit(entry, toNorm); + if (!source || !target) return { ok: false, reason: "unknown_unit" }; + + const inEntryUnit = shiftPowerOfTen(value, source.shift); + const hub = source.affine.a * inEntryUnit + source.affine.b; + const inTargetEntryUnit = (hub - target.affine.b) / target.affine.a; + // `target.shift` moves the entry's unit to the one asked for: the reverse of + // the way `from` was moved in. + const result = shiftPowerOfTen(inTargetEntryUnit, -target.shift); + return Number.isFinite(result) + ? { ok: true, value: result, via: "analyte" } + : { ok: false, reason: "not_finite" }; +} + +export type ConvertedRange = + | { + ok: true; + low: number | null; + high: number | null; + via: "analyte" | "scale"; + } + | { ok: false; reason: ConversionRefusal }; + +/** + * Both bounds of a reference range, or the refusal. An open bound (null) stays + * open. Every conversion has a positive slope, so the bounds keep their order. + */ +export function convertLabRange(input: { + analyteKey: string | null | undefined; + low: number | null; + high: number | null; + from: string; + to: string; +}): ConvertedRange { + const { analyteKey, from, to } = input; + const bounds: Array = [input.low, input.high]; + const converted: Array = []; + let via: "analyte" | "scale" = "scale"; + for (const bound of bounds) { + if (bound === null) { + converted.push(null); + continue; + } + const result = convertLabValue({ analyteKey, value: bound, from, to }); + if (!result.ok) return result; + if (result.via === "analyte") via = "analyte"; + converted.push(result.value); + } + // A range of nothing but open bounds still has to name a way: the units must + // convert at all. + if (input.low === null && input.high === null) { + const probe = convertLabValue({ analyteKey, value: 1, from, to }); + if (!probe.ok) return probe; + via = probe.via; + } + return { ok: true, low: converted[0], high: converted[1], via }; +} + +/** + * The units a reading can arrive in and be converted to `to` for this marker: + * every unit in the scale family of `to`, and, when the analyte has an entry, + * every unit of that entry along with their scale families. `to` itself is not + * listed. + */ +export function conversionsFor(input: { + analyteKey: string | null | undefined; + to: string; +}): string[] { + const to = normaliseLabUnit(input.to); + const found = new Set(scaleUnitsInFamilyOf(to)); + const entry = analyteConversionFor(input.analyteKey); + if (entry && (to in entry.units || intoEntryUnit(entry, to))) { + for (const unit of Object.keys(entry.units)) { + found.add(unit); + for (const sibling of scaleUnitsInFamilyOf(unit)) found.add(sibling); + } + } + found.delete(to); + return [...found].filter((unit) => normaliseLabUnit(unit) !== to); +} diff --git a/src/lib/labs/unit-conversion/registry.ts b/src/lib/labs/unit-conversion/registry.ts new file mode 100644 index 000000000..2ec1e2ee1 --- /dev/null +++ b/src/lib/labs/unit-conversion/registry.ts @@ -0,0 +1,231 @@ +/** + * Which analytes can be moved between units, and how. + * + * Each analyte lists the units it knows as an affine map to one hub unit: + * `hub = a * value + b`. Any pair then goes unit -> hub -> unit, so adding a + * unit to an analyte is one line and nothing lists pairs. + * + * Three kinds of entry, by where the factor comes from: + * + * - `molar-mass`: mass concentration to amount concentration, which needs the + * analyte's molar mass. The factor is DERIVED from the formula and the + * atomic weights, never typed. + * - `standard`: an equation a standards body publishes (HbA1c, IFCC to NGSP). + * - `convention`: a factor that rests on an assumed value (triglycerides take + * an average molecular weight). None is in the registry yet: a conversion + * that cannot cite a primary source stays a refusal. + * + * Every entry names its source, and an entry that does not, or whose hub is not + * one of its units, or whose factor is not a finite positive number, makes this + * module throw when it loads. A wrong factor is worse than no conversion, so an + * uncited one cannot be added quietly. + * + * Left out on purpose, so the refusal stays: haemoglobin in mmol/L (the figure + * depends on whether the monomer or the tetramer is meant, a factor of four), + * ferritin and vitamin B12 in pmol/L (large-protein molecular weights that vary + * by source), enzymes in µkat/L, and anything without a source. Urea and blood + * urea nitrogen are separate analytes (about 2.14 apart), and so are total and + * ionised calcium; only the total is here. + */ +import { MGDL_PER_MMOL } from "@/lib/glucose"; + +import { normaliseLabUnit } from "../unit-normalise"; +import { + ATOMIC_WEIGHTS_SOURCE, + formulaLabel, + molarMass, + type Formula, +} from "./atomic-weights"; +import { scaleUnitOf, shiftPowerOfTen } from "./scale"; + +export interface Affine { + a: number; + b: number; +} + +export type ConversionKind = "molar-mass" | "standard" | "convention"; + +export interface AnalyteConversion { + /** Stable identity: the catalog slug where one exists, a registry-only key otherwise. */ + key: string; + /** Other catalog slugs that are this same analyte (LDL and HDL are cholesterol). */ + aliases: readonly string[]; + /** The unit every other unit of this analyte maps to. */ + hub: string; + /** Units by their normalised spelling, each as an affine map to the hub. */ + units: Readonly>; + kind: ConversionKind; + /** Where the factor comes from; required. */ + source: string; +} + +export function defineAnalyteConversion( + entry: AnalyteConversion, +): AnalyteConversion { + const where = `unit-conversion registry entry "${entry.key}"`; + if (!entry.source.trim()) throw new Error(`${where} has no source`); + const hub = normaliseLabUnit(entry.hub); + if (!(hub in entry.units)) { + throw new Error(`${where}: hub "${entry.hub}" is not one of its units`); + } + for (const [unit, { a, b }] of Object.entries(entry.units)) { + if (normaliseLabUnit(unit) !== unit) { + throw new Error(`${where}: unit "${unit}" is not in its normalised form`); + } + if (!Number.isFinite(a) || a <= 0 || !Number.isFinite(b)) { + throw new Error(`${where}: unit "${unit}" has no finite positive factor`); + } + } + return { ...entry, hub }; +} + +/** + * A mass concentration (the hub) and one amount concentration of the same + * analyte. The factor is the molar mass moved by the powers of ten between the + * two units: 1 mmol/L of glucose is 180.156 mg/L, which is 18.0156 mg/dL. + */ +function molarMassEntry(spec: { + key: string; + aliases?: readonly string[]; + formula: Formula; + /** The mass concentration the analyte is tracked in; the hub. */ + mass: string; + /** The amount concentration it is also printed in. */ + amount: string; + /** Overrides the derived mmol/L factor with a constant the app already has. */ + factor?: number; + note?: string; +}): AnalyteConversion { + const mass = scaleUnitOf(spec.mass); + const amount = scaleUnitOf(spec.amount); + if (mass?.family !== "mass" || amount?.family !== "amount") { + throw new Error( + `molar-mass entry "${spec.key}" needs a mass and an amount unit`, + ); + } + const grams = molarMass(spec.formula); + // mol/L -> g/L is x molar mass; g/L -> the mass unit and the amount unit's + // own prefix are powers of ten. + const derived = shiftPowerOfTen(grams, amount.exponent - mass.exponent); + return defineAnalyteConversion({ + key: spec.key, + aliases: spec.aliases ?? [], + hub: spec.mass, + units: { + [normaliseLabUnit(spec.mass)]: { a: 1, b: 0 }, + [normaliseLabUnit(spec.amount)]: { a: spec.factor ?? derived, b: 0 }, + }, + kind: "molar-mass", + source: + `Molar mass of ${formulaLabel(spec.formula)} (${grams} g/mol), summed from ` + + `${ATOMIC_WEIGHTS_SOURCE}.` + + (spec.note ? ` ${spec.note}` : ""), + }); +} + +const ENTRIES: readonly AnalyteConversion[] = [ + molarMassEntry({ + key: "fasting-glucose", + formula: { C: 6, H: 12, O: 6 }, + mass: "mg/dL", + amount: "mmol/L", + // The factor the glucose screens and the CGM import already use, so the two + // can never disagree; the guard test checks it against the derived one. + factor: MGDL_PER_MMOL, + note: "The factor is MGDL_PER_MMOL (src/lib/glucose.ts).", + }), + molarMassEntry({ + key: "total-cholesterol", + aliases: ["ldl", "hdl"], + formula: { C: 27, H: 46, O: 1 }, + mass: "mg/dL", + amount: "mmol/L", + note: "LDL and HDL cholesterol are the same molecule.", + }), + molarMassEntry({ + key: "creatinine", + formula: { C: 4, H: 7, N: 3, O: 1 }, + mass: "mg/dL", + amount: "µmol/L", + }), + molarMassEntry({ + key: "urea", + formula: { C: 1, H: 4, N: 2, O: 1 }, + mass: "mg/dL", + amount: "mmol/L", + note: "Urea itself, not urea nitrogen.", + }), + molarMassEntry({ + key: "blood-urea-nitrogen", + // Only the two nitrogen atoms of urea count: BUN is mg of nitrogen per dL. + formula: { N: 2 }, + mass: "mg/dL", + amount: "mmol/L", + note: "BUN in mg/dL against urea in mmol/L; two nitrogen atoms per urea molecule.", + }), + molarMassEntry({ + key: "uric-acid", + formula: { C: 5, H: 4, N: 4, O: 3 }, + mass: "mg/dL", + amount: "µmol/L", + }), + molarMassEntry({ + key: "calcium-total", + formula: { Ca: 1 }, + mass: "mg/dL", + amount: "mmol/L", + note: "Total calcium; ionised calcium is a different measurement.", + }), + molarMassEntry({ + key: "bilirubin-total", + formula: { C: 33, H: 36, N: 4, O: 6 }, + mass: "mg/dL", + amount: "µmol/L", + }), + molarMassEntry({ + key: "vitamin-d", + formula: { C: 27, H: 44, O: 2 }, + mass: "ng/mL", + amount: "nmol/L", + note: "25-hydroxyvitamin D, taken as cholecalciferol (C27H44O2).", + }), + defineAnalyteConversion({ + key: "hba1c", + aliases: [], + hub: "%", + units: { + "%": { a: 1, b: 0 }, + // NGSP(%) = 0.09148 x IFCC(mmol/mol) + 2.152. Affine, not a factor. + "mmol/mol": { a: 0.09148, b: 2.152 }, + }, + kind: "standard", + source: + "IFCC-NGSP master equation, Hoelzel W et al., Clin Chem 2004;50(1):166-174, " + + "as published by the NGSP: NGSP (%) = 0.09148 x IFCC (mmol/mol) + 2.152.", + }), +]; + +const BY_KEY: ReadonlyMap = (() => { + const map = new Map(); + for (const entry of ENTRIES) { + for (const key of [entry.key, ...entry.aliases]) { + if (map.has(key)) { + throw new Error(`unit-conversion registry: "${key}" appears twice`); + } + map.set(key, entry); + } + } + return map; +})(); + +/** The conversion entry for an analyte key (or one of its aliases), if any. */ +export function analyteConversionFor( + analyteKey: string | null | undefined, +): AnalyteConversion | null { + return analyteKey ? (BY_KEY.get(analyteKey) ?? null) : null; +} + +/** Every entry, once each, for the guard test and for phase 3's key matching. */ +export function allAnalyteConversions(): readonly AnalyteConversion[] { + return ENTRIES; +} diff --git a/src/lib/labs/unit-conversion/scale.ts b/src/lib/labs/unit-conversion/scale.ts new file mode 100644 index 000000000..987f07b36 --- /dev/null +++ b/src/lib/labs/unit-conversion/scale.ts @@ -0,0 +1,130 @@ +/** + * Units that differ only by a power of ten. + * + * A pair of units inside one family cannot change the quantity, only the + * prefix, so it needs no analyte: `mg/L` to `g/dL` is the same for every + * marker. A pair across families (mass to amount, `mg/dL` to `mmol/L`) is NOT a + * scale pair, because it needs the analyte's molar mass; those live only in the + * registry, per analyte. + * + * Every unit is stored as its power of ten relative to its family's base unit, + * computed from the prefix and the volume, not typed. + */ +import { normaliseLabUnit } from "../unit-normalise"; + +type FamilyName = "mass" | "amount" | "activity" | "count"; + +const MASS_PREFIX: ReadonlyArray<[string, number]> = [ + ["g", 0], + ["mg", -3], + ["µg", -6], + ["ng", -9], + ["pg", -12], +]; +const AMOUNT_PREFIX: ReadonlyArray<[string, number]> = [ + ["mol", 0], + ["mmol", -3], + ["µmol", -6], + ["nmol", -9], + ["pmol", -12], +]; +/** Litres per volume unit, as a power of ten. */ +const VOLUME: Readonly> = { + L: 0, + dL: -1, + mL: -3, + µL: -6, +}; + +/** The mass-concentration units a lab prints, with the volumes they print on. */ +const MASS_UNITS: ReadonlyArray<[string, string]> = [ + ["g", "L"], + ["g", "dL"], + ["mg", "L"], + ["mg", "dL"], + ["µg", "L"], + ["µg", "dL"], + ["ng", "L"], + ["ng", "mL"], + ["ng", "dL"], + ["pg", "mL"], +]; + +const exponent = (table: ReadonlyArray<[string, number]>, prefix: string) => + table.find(([name]) => name === prefix)![1]; + +/** unit -> [family, power of ten relative to the family's base unit]. */ +const SCALE_UNITS: ReadonlyMap = new Map< + string, + readonly [FamilyName, number] +>([ + // Base: g/L. + ...MASS_UNITS.map( + ([prefix, volume]) => + [ + `${prefix}/${volume}`, + ["mass", exponent(MASS_PREFIX, prefix) - VOLUME[volume]], + ] as const, + ), + // Base: mol/L. + ...AMOUNT_PREFIX.map( + ([prefix, power]) => [`${prefix}/L`, ["amount", power - VOLUME.L]] as const, + ), + // Base: IU/L. `µIU/mL` is `mIU/L`. + ["IU/L", ["activity", 0]] as const, + ["mIU/L", ["activity", -3]] as const, + ["µIU/mL", ["activity", -6 - VOLUME.mL]] as const, + // Base: 10^9/L. A blood count is printed either way. + ["10⁹/L", ["count", 9]] as const, + ["10^9/L", ["count", 9]] as const, + ["G/L", ["count", 9]] as const, + ["10³/µL", ["count", 3 - VOLUME.µL]] as const, + ["10^3/µL", ["count", 3 - VOLUME.µL]] as const, + ["10¹²/L", ["count", 12]] as const, + ["10^12/L", ["count", 12]] as const, + ["T/L", ["count", 12]] as const, + ["10⁶/µL", ["count", 6 - VOLUME.µL]] as const, + ["10^6/µL", ["count", 6 - VOLUME.µL]] as const, +]); + +export interface ScaleUnit { + family: FamilyName; + /** Power of ten of one of this unit, in the family's base unit. */ + exponent: number; +} + +/** The family a unit belongs to, or null for a unit that has none. */ +export function scaleUnitOf(unit: string): ScaleUnit | null { + const found = SCALE_UNITS.get(normaliseLabUnit(unit)); + return found ? { family: found[0], exponent: found[1] } : null; +} + +/** Multiply by `10^power`, dividing for a negative power: `10^-3` is not exact. */ +export function shiftPowerOfTen(value: number, power: number): number { + if (power === 0) return value; + return power > 0 ? value * 10 ** power : value / 10 ** -power; +} + +/** + * `value` in `from`, written in `to`, when both are in the same family; + * null when either is in none or they are in different families. + */ +export function convertByScale( + value: number, + from: string, + to: string, +): number | null { + const a = scaleUnitOf(from); + const b = scaleUnitOf(to); + if (!a || !b || a.family !== b.family) return null; + return shiftPowerOfTen(value, a.exponent - b.exponent); +} + +/** Every unit that has a family, in its spelling here. */ +export function scaleUnitsInFamilyOf(unit: string): string[] { + const own = scaleUnitOf(unit); + if (!own) return []; + return [...SCALE_UNITS.entries()] + .filter(([, [family]]) => family === own.family) + .map(([spelling]) => spelling); +} From 52ea3b9c2fce84f4a08d3668dd3d1183b84d69f7 Mon Sep 17 00:00:00 2001 From: BernhardBuckel <33808384+BernhardBuckel@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:04:43 +0200 Subject: [PATCH 02/12] feat(labs): link a marker to its analyte for the unit conversion Slice 1 of phase 3 of #1095. A Biomarker row keeps only its name, so nothing said which analyte it was. This adds the explicit alias list in the catalog the issue settled on, including the registry-only keys (urea, blood urea nitrogen, uric acid, total calcium, total bilirubin), and sets biomarkers.analyte_key (from migration 0385) from it. matchAnalyteKey equals a marker's folded name against the localised names of the catalog seeds the registry knows and against the alias list. It matches the whole name only, so "Calcium (ionisiert)" is not "Calcium"; a name two analytes claim matches neither; and the marker's own unit must be one the analyte reaches, so a marker called "Glucose" in a unit the registry cannot convert stays unlinked. The key is set when a marker is created (the biomarker route, the mint used by lab writes, the legacy backfill) and, for an existing marker, by ensureAnalyteKey the first time a reading needs it. It never overwrites a key that is set. There is no boot job for the existing markers: the conversion that uses the key fills it on first use, and a sweep can be added later if wanted. The key is returned on the biomarker API and in the OpenAPI schema. Refs #1095 --- docs/api/openapi.yaml | 11 +- src/app/api/biomarkers/[id]/route.ts | 2 + src/app/api/biomarkers/route.ts | 6 + src/components/labs/types.ts | 5 + src/lib/jobs/lab-biomarker-backfill.ts | 2 + src/lib/labs/__tests__/analyte-match.test.ts | 164 ++++++++++++++++ src/lib/labs/analyte-key-store.ts | 37 ++++ src/lib/labs/analyte-match.ts | 81 ++++++++ src/lib/labs/biomarker-catalog.ts | 147 +++++++++++++++ src/lib/labs/biomarker-store.ts | 2 + src/lib/labs/unit-conversion/convert.ts | 16 ++ src/lib/openapi/routes/biomarkers.ts | 3 +- tests/integration/labs-analyte-key.test.ts | 187 +++++++++++++++++++ 13 files changed, 660 insertions(+), 3 deletions(-) create mode 100644 src/lib/labs/__tests__/analyte-match.test.ts create mode 100644 src/lib/labs/analyte-key-store.ts create mode 100644 src/lib/labs/analyte-match.ts create mode 100644 tests/integration/labs-analyte-key.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 1754488a8..5868d4c1b 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -68333,6 +68333,10 @@ components: anyOf: - type: string - type: "null" + analyteKey: + anyOf: + - type: string + - type: "null" hasContext: type: boolean context: @@ -68352,6 +68356,7 @@ components: - lowerBound - upperBound - panel + - analyteKey - hasContext - context - hidden @@ -68359,8 +68364,10 @@ components: - updatedAt additionalProperties: false description: A user-scoped catalog marker. `context` is the decrypted per-marker note (or null); `hasContext` flags its - presence. `hidden` drops the marker from the active catalog list and the lab-entry pickers without deleting it - (its readings and unit/range survive). A `LabResult` linking this marker resolves its unit + reference bounds + presence. `analyteKey` is the analyte the marker was recognised as (the unit-conversion registry's key, e.g. + `fasting-glucose`), or null when its name matches none; a reading in another unit is converted only for a marker + that has one. `hidden` drops the marker from the active catalog list and the lab-entry pickers without deleting + it (its readings and unit/range survive). A `LabResult` linking this marker resolves its unit + reference bounds from here. CreateBiomarkerResponse: type: object diff --git a/src/app/api/biomarkers/[id]/route.ts b/src/app/api/biomarkers/[id]/route.ts index a17d0b015..d3aec468b 100644 --- a/src/app/api/biomarkers/[id]/route.ts +++ b/src/app/api/biomarkers/[id]/route.ts @@ -41,6 +41,7 @@ function serialiseBiomarker(row: { lowerBound: number | null; upperBound: number | null; panel: string | null; + analyteKey: string | null; hidden: boolean; contextEncrypted: Uint8Array | null; createdAt: Date; @@ -53,6 +54,7 @@ function serialiseBiomarker(row: { lowerBound: row.lowerBound, upperBound: row.upperBound, panel: row.panel, + analyteKey: row.analyteKey, hasContext: row.contextEncrypted !== null, context: row.contextEncrypted ? decryptContextFromBytes(row.contextEncrypted) diff --git a/src/app/api/biomarkers/route.ts b/src/app/api/biomarkers/route.ts index 8de993f70..8736ab76d 100644 --- a/src/app/api/biomarkers/route.ts +++ b/src/app/api/biomarkers/route.ts @@ -17,6 +17,7 @@ import { decryptContextSoft, encryptContextToBytes, } from "@/lib/labs/biomarker-store"; +import { matchAnalyteKey } from "@/lib/labs/analyte-match"; import { annotate } from "@/lib/logging/context"; import { createBiomarkerSchema } from "@/lib/validations/biomarkers"; @@ -51,6 +52,7 @@ function serialiseBiomarker(row: { lowerBound: number | null; upperBound: number | null; panel: string | null; + analyteKey: string | null; hidden: boolean; contextEncrypted: Uint8Array | null; createdAt: Date; @@ -63,6 +65,7 @@ function serialiseBiomarker(row: { lowerBound: row.lowerBound, upperBound: row.upperBound, panel: row.panel, + analyteKey: row.analyteKey, hasContext: row.contextEncrypted !== null, context: decryptContextSoft(row.contextEncrypted), hidden: row.hidden, @@ -158,6 +161,9 @@ async function postBiomarker(request: NextRequest) { lowerBound: lowerBound ?? null, upperBound: upperBound ?? null, panel: panel ?? null, + // Which analyte it is, for the unit conversion; null when the name is no + // analyte the registry knows. + analyteKey: matchAnalyteKey({ name, unit }), contextEncrypted: context ? encryptContextToBytes(context) : null, }, }); diff --git a/src/components/labs/types.ts b/src/components/labs/types.ts index 2f7eae4a4..b72b4d0df 100644 --- a/src/components/labs/types.ts +++ b/src/components/labs/types.ts @@ -60,6 +60,11 @@ export interface BiomarkerDto { lowerBound: number | null; upperBound: number | null; panel: string | null; + /** + * Which analyte the marker is, for converting a reading in another unit + * (the unit-conversion registry's key), or null when it is none that is known. + */ + analyteKey: string | null; hasContext: boolean; context: string | null; /** v1.22 — a hidden marker drops from the active list + lab-entry pickers. */ diff --git a/src/lib/jobs/lab-biomarker-backfill.ts b/src/lib/jobs/lab-biomarker-backfill.ts index 61ffd162f..8481adae3 100644 --- a/src/lib/jobs/lab-biomarker-backfill.ts +++ b/src/lib/jobs/lab-biomarker-backfill.ts @@ -28,6 +28,7 @@ import { prisma } from "@/lib/db"; import { invalidateUserHealthScore } from "@/lib/cache/invalidate"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; import { integrationBackfillSourceOptions } from "@/lib/jobs/integration-backfill-admission"; +import { matchAnalyteKey } from "@/lib/labs/analyte-match"; import { annotate } from "@/lib/logging/context"; export const LAB_BIOMARKER_BACKFILL_QUEUE = "lab-biomarker-backfill"; @@ -161,6 +162,7 @@ export async function runLabBiomarkerBackfillForUser( lowerBound: group.referenceLow, upperBound: group.referenceHigh, panel: group.panel, + analyteKey: matchAnalyteKey({ name: group.name, unit: group.unit }), }, }); markers += 1; diff --git a/src/lib/labs/__tests__/analyte-match.test.ts b/src/lib/labs/__tests__/analyte-match.test.ts new file mode 100644 index 000000000..28cc86302 --- /dev/null +++ b/src/lib/labs/__tests__/analyte-match.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from "vitest"; + +import { foldForMatch } from "@/lib/i18n/fold-for-match"; + +import { matchAnalyteKey, analyteNameIndex } from "../analyte-match"; +import { ANALYTE_ALIASES, BIOMARKER_CATALOG } from "../biomarker-catalog"; +import { analyteAcceptsUnit } from "../unit-conversion/convert"; +import { + allAnalyteConversions, + analyteConversionFor, +} from "../unit-conversion/registry"; + +const match = (name: string, unit: string) => matchAnalyteKey({ name, unit }); + +describe("the catalog's own names, in every language", () => { + it.each([ + ["Fasting glucose", "mg/dL", "fasting-glucose"], + ["Nüchternglukose", "mg/dL", "fasting-glucose"], + ["Glucosa en ayunas", "mg/dL", "fasting-glucose"], + ["Glycémie à jeun", "mmol/L", "fasting-glucose"], + ["Glicemia a digiuno", "mg/dL", "fasting-glucose"], + ["공복혈당", "mg/dL", "fasting-glucose"], + ["Glukoza na czczo", "mg/dL", "fasting-glucose"], + ["HbA1c", "%", "hba1c"], + ["Total cholesterol", "mg/dL", "total-cholesterol"], + ["Gesamtcholesterin", "mg/dL", "total-cholesterol"], + ["Cholestérol total", "mmol/L", "total-cholesterol"], + ["LDL cholesterol", "mg/dL", "ldl"], + ["LDL-Cholesterin", "mg/dL", "total-cholesterol"], + ["Colesterolo HDL", "mg/dL", "total-cholesterol"], + ["Vitamin D (25-OH)", "ng/mL", "vitamin-d"], + ["Créatinine", "mg/dL", "creatinine"], + ["Kreatynina", "µmol/L", "creatinine"], + ])("%s (%s) is %s", (name, unit, key) => { + // LDL and HDL are aliases of cholesterol: the registry reports one key. + const expected = analyteConversionFor(key)!.key; + expect(match(name, unit)).toBe(expected); + }); + + it("recognises a seed typed without the app's gloss", () => { + expect(match("Vitamin D", "ng/mL")).toBe("vitamin-d"); + }); +}); + +describe("typed names", () => { + it.each([ + ["Glucose", "mg/dL", "fasting-glucose"], + ["Blutzucker", "mg/dL", "fasting-glucose"], + ["blood sugar", "mmol/L", "fasting-glucose"], + ["Cholesterin", "mg/dL", "total-cholesterol"], + ["Serum Creatinine", "mg/dL", "creatinine"], + ["Urea", "mmol/L", "urea"], + ["Harnstoff", "mg/dL", "urea"], + ["BUN", "mg/dL", "blood-urea-nitrogen"], + ["Blood Urea Nitrogen", "mg/dL", "blood-urea-nitrogen"], + ["Harnsäure", "µmol/L", "uric-acid"], + ["Uric acid", "mg/dL", "uric-acid"], + ["Kalzium", "mmol/L", "calcium-total"], + ["Calcium", "mg/dL", "calcium-total"], + ["Gesamtbilirubin", "µmol/L", "bilirubin-total"], + ["Bilirubin total", "mg/dL", "bilirubin-total"], + ])("%s (%s) is %s", (name, unit, key) => { + expect(match(name, unit)).toBe(key); + }); + + it("folds case, accents, hyphens and spacing the same on both sides", () => { + expect(match(" HARNSÄURE ", "mg/dL")).toBe("uric-acid"); + expect(match("ldl-c", "mg/dL")).toBe(analyteConversionFor("ldl")!.key); + expect(match("blood urea-nitrogen", "mg/dL")).toBe("blood-urea-nitrogen"); + }); +}); + +describe("a name that is not the whole analyte name stays unlinked", () => { + it.each([ + ["Calcium (ionisiert)", "mmol/L"], + ["Ionised calcium", "mmol/L"], + ["Calcium ionisiert", "mmol/L"], + ["Urea nitrogen ratio", "mg/dL"], + ["Glucose (urine)", "mg/dL"], + ["Glucose tolerance", "mg/dL"], + ["Non-HDL cholesterol", "mg/dL"], + ["Cholesterol ratio", "mg/dL"], + ["Direct bilirubin", "mg/dL"], + ["Free thyroxine", "pmol/L"], + ["", "mg/dL"], + ])("%s", (name, unit) => { + expect(match(name, unit)).toBeNull(); + }); + + it("keeps urea and blood urea nitrogen apart", () => { + expect(match("Urea", "mmol/L")).toBe("urea"); + expect(match("Urea nitrogen", "mg/dL")).toBe("blood-urea-nitrogen"); + }); +}); + +describe("the marker's unit must be one the analyte reaches", () => { + it("links a unit the entry lists and one that differs by a power of ten", () => { + expect(match("Glucose", "mg/dL")).toBe("fasting-glucose"); + expect(match("Glucose", "mmol/L")).toBe("fasting-glucose"); + expect(match("Glucose", "g/L")).toBe("fasting-glucose"); + expect(match("Creatinine", "mmol/L")).toBe("creatinine"); + }); + + it("leaves a unit the registry cannot reach unlinked", () => { + expect(match("Glucose", "mEq/L")).toBeNull(); + expect(match("Glucose", "%")).toBeNull(); + expect(match("HbA1c", "mg/dL")).toBeNull(); + expect(match("Calcium", "furlongs")).toBeNull(); + }); + + it("reads a spelling of the unit through the normaliser", () => { + expect(match("Glucose", "MG/DL")).toBe("fasting-glucose"); + expect(match("Creatinine", "umol/l")).toBe("creatinine"); + }); +}); + +describe("the alias list, as a guard", () => { + it("names only analytes the registry knows", () => { + for (const { key } of ANALYTE_ALIASES) { + expect(analyteConversionFor(key), key).not.toBeNull(); + } + }); + + it("writes every name already folded, so what is read is what is matched", () => { + for (const { key, names } of ANALYTE_ALIASES) { + for (const name of names) { + expect(foldForMatch(name), `${key}: ${name}`).toBe(name); + } + } + }); + + it("has no name that two analytes claim", () => { + expect(analyteNameIndex().ambiguous).toEqual([]); + }); + + it("gives every registry entry at least one name to be found by", () => { + const { index } = analyteNameIndex(); + const reached = new Set(index.values()); + for (const entry of allAnalyteConversions()) { + expect(reached.has(entry.key), entry.key).toBe(true); + } + }); + + it("covers every catalog seed the registry knows, in all seven languages", () => { + const { index } = analyteNameIndex(); + const seeds = BIOMARKER_CATALOG.filter((seed) => + analyteConversionFor(seed.slug), + ); + expect(seeds.length).toBeGreaterThanOrEqual(6); + for (const seed of seeds) { + const key = analyteConversionFor(seed.slug)!.key; + const names = [...index.entries()].filter(([, k]) => k === key); + expect(names.length, seed.slug).toBeGreaterThan(0); + } + }); + + it("links each seed at its own catalog unit", () => { + for (const seed of BIOMARKER_CATALOG) { + const entry = analyteConversionFor(seed.slug); + if (!entry) continue; + expect(analyteAcceptsUnit(entry.key, seed.unit), seed.slug).toBe(true); + } + }); +}); diff --git a/src/lib/labs/analyte-key-store.ts b/src/lib/labs/analyte-key-store.ts new file mode 100644 index 000000000..f11720e53 --- /dev/null +++ b/src/lib/labs/analyte-key-store.ts @@ -0,0 +1,37 @@ +/** + * Link a marker to its analyte for the unit conversion. + * + * The key is set when a marker is created and, for a marker that already + * existed, the first time a reading needs it. It is never overwritten: a key a + * person (or an earlier pass) set stands, and `updateMany` is gated on the + * column still being null, so two concurrent writers converge. A marker whose + * name matches no analyte keeps a null key and gets no conversion. + */ +import type { PrismaClient } from "@/generated/prisma/client"; + +import { matchAnalyteKey } from "./analyte-match"; + +export async function ensureAnalyteKey( + client: Pick, + marker: { + id: string; + name: string; + unit: string; + analyteKey?: string | null; + }, +): Promise { + if (marker.analyteKey) return marker.analyteKey; + const key = matchAnalyteKey({ name: marker.name, unit: marker.unit }); + if (!key) return null; + const { count } = await client.biomarker.updateMany({ + where: { id: marker.id, analyteKey: null }, + data: { analyteKey: key }, + }); + if (count > 0) return key; + // Another writer set it first; read what stands. + const current = await client.biomarker.findUnique({ + where: { id: marker.id }, + select: { analyteKey: true }, + }); + return current?.analyteKey ?? null; +} diff --git a/src/lib/labs/analyte-match.ts b/src/lib/labs/analyte-match.ts new file mode 100644 index 000000000..0e04d48f3 --- /dev/null +++ b/src/lib/labs/analyte-match.ts @@ -0,0 +1,81 @@ +/** + * Which analyte a marker is, for the unit conversion. + * + * `matchAnalyteKey` answers from two places: the localised names of the + * catalog seeds the registry knows (so a marker created from the "pick a common + * marker" list is recognised in every language the app ships) and the alias + * list in the catalog. The name must equal one of them as a WHOLE, folded the + * same way on both sides; a part of a name never matches. + * + * Two safeguards keep a link from being wrong: + * - a folded name that two analytes claim matches neither; + * - the marker's own unit must be one the analyte reaches, so a marker called + * "Glucose" in a unit the registry cannot convert is left unlinked. + * + * Returns the registry key, or null. Pure apart from reading the message + * bundles once. + */ +import { foldForMatch } from "@/lib/i18n/fold-for-match"; +import { localisedValues } from "@/lib/i18n/shared-resolve"; + +import { ANALYTE_ALIASES, BIOMARKER_CATALOG } from "./biomarker-catalog"; +import { analyteAcceptsUnit } from "./unit-conversion/convert"; +import { analyteConversionFor } from "./unit-conversion/registry"; + +/** The folded name without a trailing parenthetical gloss: "Vitamin D (25-OH)" -> "vitamin d". */ +function withoutGloss(folded: string): string { + return folded.replace(/\s*\([^)]*\)/gu, "").trim(); +} + +let cache: ReadonlyMap | null = null; + +/** + * folded name -> registry key, with every name two analytes claim removed. + * Exported for the guard test, which asserts nothing was dropped silently. + */ +export function analyteNameIndex(): { + index: ReadonlyMap; + ambiguous: readonly string[]; +} { + const claims = new Map>(); + const claim = (name: string, key: string) => { + const folded = foldForMatch(name); + if (!folded) return; + const keys = claims.get(folded) ?? new Set(); + keys.add(key); + claims.set(folded, keys); + }; + + for (const seed of BIOMARKER_CATALOG) { + const entry = analyteConversionFor(seed.slug); + if (!entry) continue; + for (const name of localisedValues(`labs.catalog.${seed.slug}`)) { + claim(name, entry.key); + // The bare name too, for a marker typed without the app's gloss. + claim(withoutGloss(foldForMatch(name)), entry.key); + } + } + for (const { key, names } of ANALYTE_ALIASES) { + const entry = analyteConversionFor(key); + if (!entry) continue; + for (const name of names) claim(name, entry.key); + } + + const index = new Map(); + const ambiguous: string[] = []; + for (const [folded, keys] of claims) { + if (keys.size === 1) index.set(folded, [...keys][0]); + else ambiguous.push(folded); + } + return { index, ambiguous }; +} + +export function matchAnalyteKey(input: { + name: string; + unit: string; +}): string | null { + cache ??= analyteNameIndex().index; + const key = cache.get(foldForMatch(input.name)); + if (!key) return null; + return analyteAcceptsUnit(key, input.unit) ? key : null; +} diff --git a/src/lib/labs/biomarker-catalog.ts b/src/lib/labs/biomarker-catalog.ts index 6b33f8569..3bcf120af 100644 --- a/src/lib/labs/biomarker-catalog.ts +++ b/src/lib/labs/biomarker-catalog.ts @@ -276,3 +276,150 @@ export const BIOMARKER_PANELS = [ "electrolytes", "bloodCount", ] as const; + +/** + * Names a marker may carry for an analyte the unit-conversion registry knows + * (`src/lib/labs/unit-conversion/registry.ts`), keyed by the registry's key. + * + * A `Biomarker` row keeps only its name, not the seed it came from, so this is + * how a marker is told which analyte it is: its folded name equals one of these, + * or one of the localised names of a seed the registry knows (`ldl`, `hba1c`, + * `fasting-glucose`, ...). The match is the WHOLE name, never a part of it, so + * "Calcium (ionisiert)" is not "Calcium" and "Urea nitrogen" is not "Urea". A + * name that two analytes claim is left out of the match, and a marker named in + * some other way stays unlinked, which is the safe default. + * + * Names are compared folded (lower case, no accents, hyphens and slashes as + * spaces), so "Harnsäure" is written "harnsaure" here and "Nüchtern-Glukose" + * needs no entry of its own. The registry-only keys (urea, blood urea + * nitrogen, uric acid, total calcium, total bilirubin) have no seed, so their + * names live only here. + */ +export interface AnalyteAliases { + /** The registry key of the analyte. */ + key: string; + names: readonly string[]; +} + +export const ANALYTE_ALIASES: readonly AnalyteAliases[] = [ + { + key: "fasting-glucose", + names: [ + "glucose", + "blood glucose", + "blood sugar", + "plasma glucose", + "fasting blood glucose", + "fasting blood sugar", + "fasting plasma glucose", + "glukose", + "blutglukose", + "blutzucker", + "nuchternblutzucker", + "glucosa", + "glicemia", + "glycemie", + "glucosio", + "glukoza", + "glikemia", + ], + }, + { + key: "hba1c", + names: [ + "hb a1c", + "a1c", + "hemoglobin a1c", + "glycated hemoglobin", + "glycosylated hemoglobin", + "glykohamoglobin", + "hamoglobin a1c", + ], + }, + { + key: "total-cholesterol", + names: [ + "cholesterol", + "cholesterin", + "cholesterol total", + "cholesterin gesamt", + "cholesterol gesamt", + "colesterol", + "colesterolo", + "cholesterol calkowity", + ], + }, + { key: "ldl", names: ["ldl", "ldl c", "ldl chol", "ldl cholesterin"] }, + { key: "hdl", names: ["hdl", "hdl c", "hdl chol", "hdl cholesterin"] }, + { + key: "creatinine", + names: [ + "serum creatinine", + "serumkreatinin", + "kreatinin im serum", + "creatinine serique", + ], + }, + { + key: "vitamin-d", + names: [ + "25 oh vitamin d", + "25 hydroxy vitamin d", + "25 hydroxyvitamin d", + "25 oh d", + "calcidiol", + "vitamin d 25 oh", + ], + }, + { key: "urea", names: ["urea", "harnstoff", "uree", "urea serum"] }, + { + key: "blood-urea-nitrogen", + names: [ + "bun", + "blood urea nitrogen", + "urea nitrogen", + "harnstoff stickstoff", + "stickstoff harnstoff", + "azote ureique", + ], + }, + { + key: "uric-acid", + names: [ + "uric acid", + "urate", + "harnsaure", + "acide urique", + "acido urico", + "kwas moczowy", + ], + }, + { + key: "calcium-total", + names: [ + "calcium", + "total calcium", + "calcium total", + "calcium gesamt", + "gesamtcalcium", + "kalzium", + "kalzium gesamt", + "gesamtkalzium", + "calcio", + "wapn", + ], + }, + { + key: "bilirubin-total", + names: [ + "bilirubin", + "total bilirubin", + "bilirubin total", + "bilirubin gesamt", + "gesamtbilirubin", + "bilirrubina", + "bilirubine", + "bilirubina", + ], + }, +]; diff --git a/src/lib/labs/biomarker-store.ts b/src/lib/labs/biomarker-store.ts index f1e11411e..d987fc8ba 100644 --- a/src/lib/labs/biomarker-store.ts +++ b/src/lib/labs/biomarker-store.ts @@ -13,6 +13,7 @@ import { Buffer } from "node:buffer"; import { decrypt, encrypt } from "@/lib/crypto"; import { prisma } from "@/lib/db"; +import { matchAnalyteKey } from "@/lib/labs/analyte-match"; import { getEvent } from "@/lib/logging/context"; /** Encrypt a UTF-8 context note into the `Bytes` payload the schema stores. */ @@ -120,6 +121,7 @@ export async function resolveOrMintBiomarker( lowerBound: input.referenceLow, upperBound: input.referenceHigh, panel: input.panel, + analyteKey: matchAnalyteKey({ name, unit: input.unit }), }, select, }); diff --git a/src/lib/labs/unit-conversion/convert.ts b/src/lib/labs/unit-conversion/convert.ts index 9e108489b..1c79b5e2a 100644 --- a/src/lib/labs/unit-conversion/convert.ts +++ b/src/lib/labs/unit-conversion/convert.ts @@ -165,3 +165,19 @@ export function conversionsFor(input: { found.delete(to); return [...found].filter((unit) => normaliseLabUnit(unit) !== to); } + +/** + * Whether the analyte's entry reaches `unit`: the unit is one it lists, or + * differs from one only by a power of ten. A marker is linked to an analyte only + * when its own unit passes this, so a marker called "Glucose" tracked in some + * unit the registry cannot reach is not given a conversion it cannot make. + */ +export function analyteAcceptsUnit( + analyteKey: string | null | undefined, + unit: string, +): boolean { + const entry = analyteConversionFor(analyteKey); + return ( + entry !== null && intoEntryUnit(entry, normaliseLabUnit(unit)) !== null + ); +} diff --git a/src/lib/openapi/routes/biomarkers.ts b/src/lib/openapi/routes/biomarkers.ts index d210b0b84..6ea98d8c8 100644 --- a/src/lib/openapi/routes/biomarkers.ts +++ b/src/lib/openapi/routes/biomarkers.ts @@ -53,6 +53,7 @@ const biomarkerRow = z lowerBound: z.number().nullable(), upperBound: z.number().nullable(), panel: z.string().nullable(), + analyteKey: z.string().nullable(), hasContext: z.boolean(), context: z.string().nullable(), hidden: z.boolean(), @@ -62,7 +63,7 @@ const biomarkerRow = z .meta({ id: "Biomarker", description: - "A user-scoped catalog marker. `context` is the decrypted per-marker note (or null); `hasContext` flags its presence. `hidden` drops the marker from the active catalog list and the lab-entry pickers without deleting it (its readings and unit/range survive). A `LabResult` linking this marker resolves its unit + reference bounds from here.", + "A user-scoped catalog marker. `context` is the decrypted per-marker note (or null); `hasContext` flags its presence. `analyteKey` is the analyte the marker was recognised as (the unit-conversion registry's key, e.g. `fasting-glucose`), or null when its name matches none; a reading in another unit is converted only for a marker that has one. `hidden` drops the marker from the active catalog list and the lab-entry pickers without deleting it (its readings and unit/range survive). A `LabResult` linking this marker resolves its unit + reference bounds from here.", }); const listResponse = z diff --git a/tests/integration/labs-analyte-key.test.ts b/tests/integration/labs-analyte-key.test.ts new file mode 100644 index 000000000..df12eb7c3 --- /dev/null +++ b/tests/integration/labs-analyte-key.test.ts @@ -0,0 +1,187 @@ +/** + * A marker is linked to its analyte, once, and the link is never overwritten. + * + * `Biomarker.analyteKey` says which analyte a marker is, so a reading in another + * unit can be converted (#1095). It is set when a marker is created, by all + * three creation paths, and, for a marker that already exists, the first time + * it is needed. Against a real Postgres: + * - a marker created by name is linked when the name is a known analyte and + * its unit is one the analyte reaches, and left null otherwise; + * - the key reaches the API response; + * - `ensureAnalyteKey` fills a null key once and never replaces a key that is + * set, even by a different match, and two concurrent calls converge; + * - the mint path used by the lab writes and the legacy backfill set it too. + */ +import { NextRequest } from "next/server"; +import { beforeAll, describe, expect, it, vi } from "vitest"; + +import { cookieJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; +import { runLabBiomarkerBackfillForUser } from "@/lib/jobs/lab-biomarker-backfill"; +import { ensureAnalyteKey } from "@/lib/labs/analyte-key-store"; +import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const prisma = getPrismaClient(); +const USER = "analyte-key-user"; + +beforeAll(async () => { + await truncateAllTables(prisma); + await prisma.user.create({ data: { id: USER, username: USER } }); +}); + +async function post(body: Record) { + const s = await prisma.session.create({ + data: { userId: USER, expiresAt: new Date(Date.now() + 3_600_000) }, + }); + cookieJar.clear(); + cookieJar.set("healthlog_session", s.id); + const { POST } = await import("@/app/api/biomarkers/route"); + return POST( + new NextRequest("http://localhost/api/biomarkers", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }), + ); +} + +describe("POST /api/biomarkers", () => { + it("links a marker whose name is a known analyte, and returns the key", async () => { + const res = await post({ name: "Blutzucker", unit: "mg/dL" }); + expect(res.status).toBe(201); + const { data } = (await res.json()) as { data: { analyteKey: string } }; + expect(data.analyteKey).toBe("fasting-glucose"); + const row = await prisma.biomarker.findFirst({ + where: { userId: USER, name: "Blutzucker" }, + }); + expect(row?.analyteKey).toBe("fasting-glucose"); + }); + + it("links a catalog seed by its localised name", async () => { + const res = await post({ name: "Gesamtcholesterin", unit: "mg/dL" }); + const { data } = (await res.json()) as { data: { analyteKey: string } }; + expect(data.analyteKey).toBe("total-cholesterol"); + }); + + it("leaves a free name, and a known name in an unreachable unit, unlinked", async () => { + for (const [name, unit] of [ + ["My own marker", "mg/dL"], + ["Glucose", "mEq/L"], + ["Calcium (ionisiert)", "mmol/L"], + ]) { + const res = await post({ name, unit }); + expect(res.status).toBe(201); + const { data } = (await res.json()) as { + data: { analyteKey: string | null }; + }; + expect(data.analyteKey, name).toBeNull(); + } + }); +}); + +describe("ensureAnalyteKey", () => { + it("fills a null key once", async () => { + const marker = await prisma.biomarker.create({ + data: { userId: USER, name: "Harnsäure", unit: "µmol/L" }, + }); + expect(marker.analyteKey).toBeNull(); + expect(await ensureAnalyteKey(prisma, marker)).toBe("uric-acid"); + expect( + (await prisma.biomarker.findUnique({ where: { id: marker.id } })) + ?.analyteKey, + ).toBe("uric-acid"); + }); + + it("never replaces a key that is set, even when the name would match another", async () => { + const marker = await prisma.biomarker.create({ + data: { + userId: USER, + name: "Blood glucose", + unit: "mg/dL", + analyteKey: "hba1c", + }, + }); + // The caller read the row with the key set. + expect(await ensureAnalyteKey(prisma, marker)).toBe("hba1c"); + // A caller holding a stale copy with a null key does not overwrite either. + expect( + await ensureAnalyteKey(prisma, { ...marker, analyteKey: null }), + ).toBe("hba1c"); + expect( + (await prisma.biomarker.findUnique({ where: { id: marker.id } })) + ?.analyteKey, + ).toBe("hba1c"); + }); + + it("converges when two callers fill the same marker", async () => { + const marker = await prisma.biomarker.create({ + data: { userId: USER, name: "Kreatinin", unit: "mg/dL" }, + }); + const [a, b] = await Promise.all([ + ensureAnalyteKey(prisma, marker), + ensureAnalyteKey(prisma, marker), + ]); + expect(a).toBe("creatinine"); + expect(b).toBe("creatinine"); + }); + + it("leaves a marker with no analyte null", async () => { + const marker = await prisma.biomarker.create({ + data: { userId: USER, name: "Ferritin", unit: "ng/mL" }, + }); + expect(await ensureAnalyteKey(prisma, marker)).toBeNull(); + }); +}); + +describe("the other creation paths", () => { + it("links a marker minted by a lab write", async () => { + const minted = await resolveOrMintBiomarker(USER, { + analyte: "Gesamtbilirubin", + unit: "µmol/L", + referenceLow: null, + referenceHigh: null, + panel: null, + }); + const row = await prisma.biomarker.findUnique({ where: { id: minted.id } }); + expect(row?.analyteKey).toBe("bilirubin-total"); + }); + + it("links a marker the legacy backfill mints", async () => { + await prisma.labResult.create({ + data: { + userId: USER, + analyte: "Kalzium", + value: 2.4, + unit: "mmol/L", + takenAt: new Date("2026-05-01T08:00:00Z"), + source: "MANUAL", + }, + }); + await runLabBiomarkerBackfillForUser(USER); + const row = await prisma.biomarker.findFirst({ + where: { userId: USER, name: "Kalzium" }, + }); + expect(row?.analyteKey).toBe("calcium-total"); + }); +}); From 08c69d4a5a1c03d5792f5b195074414f33f5e96c Mon Sep 17 00:00:00 2001 From: BernhardBuckel <33808384+BernhardBuckel@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:16:31 +0200 Subject: [PATCH 03/12] test(labs): give the biomarker fixtures the new analyteKey field BiomarkerDto gained analyteKey, and two component test fixtures typed as it were missing the field, which the typecheck job rejects. --- src/components/labs/__tests__/biomarker-form.test.tsx | 1 + src/components/labs/__tests__/lab-biomarker-detail.test.tsx | 1 + 2 files changed, 2 insertions(+) diff --git a/src/components/labs/__tests__/biomarker-form.test.tsx b/src/components/labs/__tests__/biomarker-form.test.tsx index 43d77f898..50333598e 100644 --- a/src/components/labs/__tests__/biomarker-form.test.tsx +++ b/src/components/labs/__tests__/biomarker-form.test.tsx @@ -28,6 +28,7 @@ const MARKER: BiomarkerDto = { lowerBound: 70, upperBound: 99, panel: null, + analyteKey: null, hasContext: false, context: null, hidden: false, diff --git a/src/components/labs/__tests__/lab-biomarker-detail.test.tsx b/src/components/labs/__tests__/lab-biomarker-detail.test.tsx index 9a969fcc0..17e2cbc4b 100644 --- a/src/components/labs/__tests__/lab-biomarker-detail.test.tsx +++ b/src/components/labs/__tests__/lab-biomarker-detail.test.tsx @@ -51,6 +51,7 @@ const MARKER: BiomarkerDto = { lowerBound: 0, upperBound: 100, panel: null, + analyteKey: null, hasContext: false, context: null, hidden: false, From caa7a937fdfa06546105ecc78599cef24d60e641 Mon Sep 17 00:00:00 2001 From: BernhardBuckel <33808384+BernhardBuckel@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:28:00 +0200 Subject: [PATCH 04/12] feat(labs): convert a reading into its marker's unit when the pair is known Slice 2 of phase 3 of #1095. A reading that stated a unit other than its marker's was refused. When the marker's analyte is known and the registry has the pair, the three write paths now convert it instead: POST /api/labs, the OCR commit and the document commit. The converted value is stored at full precision in the marker's unit, with what the report printed in the existing source_value and source_unit columns. The report's own reference window is in the printed unit, so it is converted with the value; otherwise the verdict would compare a converted value with an unconverted window. A pair the registry does not have is still refused as before (the 422 now says why), as is a marker whose analyte is unknown. The marker's key is filled the first time a reading needs it. The same scan committed again is still a duplicate: a converted row holds the number in the marker's unit, so the duplicate check also matches on what the report printed. Editing the value of a converted reading clears the conversion record, because the number is then the person's own. The lab result DTO and the OpenAPI schemas (labs and the OCR commit) carry converted, sourceValue and sourceUnit. Refs #1095 --- docs/api/openapi.yaml | 62 +++- ...en-outcome-response-consumer-guard.test.ts | 3 + src/app/api/labs/[id]/route.ts | 10 +- src/app/api/labs/ocr/commit/route.ts | 77 ++++- src/app/api/labs/route.ts | 84 +++-- .../__tests__/lab-biomarker-detail.test.tsx | 3 + src/components/labs/types.ts | 8 + src/lib/documents/__tests__/commit.test.ts | 84 ++++- src/lib/documents/commit.ts | 49 ++- .../labs/__tests__/reading-conversion.test.ts | 187 +++++++++++ .../resolve-or-mint-biomarker.test.ts | 3 + src/lib/labs/biomarker-store.ts | 2 + src/lib/labs/reading-conversion.ts | 105 ++++++ src/lib/labs/serialise.ts | 30 ++ src/lib/openapi/routes/labs.ts | 7 +- src/lib/openapi/routes/ocr.ts | 7 +- tests/integration/lab-unit-mismatch.test.ts | 79 +++-- .../integration/labs-unit-conversion.test.ts | 316 ++++++++++++++++++ 18 files changed, 1011 insertions(+), 105 deletions(-) create mode 100644 src/lib/labs/__tests__/reading-conversion.test.ts create mode 100644 src/lib/labs/reading-conversion.ts create mode 100644 tests/integration/labs-unit-conversion.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 5868d4c1b..d6dae8879 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -14905,11 +14905,14 @@ paths: "403": *a11 "409": *a7 "422": - description: Nothing was written. `meta.errorCode` = `labs.create.invalid` when the body failed validation (every issue + description: "Nothing was written. `meta.errorCode` = `labs.create.invalid` when the body failed validation (every issue under `details.issues`), or `labs.unit.mismatch` when a numeric reading states a `unit` other than the one - its marker is tracked in (`meta.markerUnit`, `meta.readingUnit`). A unit is compared only when one is sent; - with a `biomarkerId` it may be omitted and the marker's unit is then the stated one. Spelling is forgiven - (`mmol/l`, `ug/L` for `µg/L`); a different unit is refused, never relabelled. + its marker is tracked in AND the pair cannot be converted (`meta.markerUnit`, `meta.readingUnit`, + `meta.reason` = `unknown_analyte` | `unknown_unit` | `not_finite`). A unit is compared only when one is + sent; with a `biomarkerId` it may be omitted and the marker's unit is then the stated one. Spelling is + forgiven (`mmol/l`, `ug/L` for `µg/L`); a different unit is converted when the marker's analyte is known and + the pair is in the unit-conversion registry (the 201 response then carries `converted: true`), and refused + otherwise, never relabelled." content: application/json: schema: @@ -67394,6 +67397,16 @@ components: - type: "null" unit: type: string + converted: + type: boolean + sourceValue: + anyOf: + - type: number + - type: "null" + sourceUnit: + anyOf: + - type: string + - type: "null" referenceLow: anyOf: - type: number @@ -67446,6 +67459,9 @@ components: - value - valueText - unit + - converted + - sourceValue + - sourceUnit - referenceLow - referenceHigh - catalogReferenceLow @@ -67476,8 +67492,11 @@ components: 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. The encrypted note is never echoed in list rows; - `hasNote` flags its presence and the single-resource GET returns the decrypted `note`. + 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`. LabReferenceRangeOrigin: type: string enum: @@ -67556,6 +67575,16 @@ components: - type: "null" unit: type: string + converted: + type: boolean + sourceValue: + anyOf: + - type: number + - type: "null" + sourceUnit: + anyOf: + - type: string + - type: "null" referenceLow: anyOf: - type: number @@ -67610,6 +67639,9 @@ components: - value - valueText - unit + - converted + - sourceValue + - sourceUnit - referenceLow - referenceHigh - catalogReferenceLow @@ -67820,7 +67852,8 @@ components: - duplicate - unit_mismatch description: "`duplicate`: the row matched a live reading at commit time. `unit_mismatch`: the row states a unit other - than the one its marker is tracked in, so it was not written." + than the one its marker is tracked in and the pair cannot be converted, so it was not written. A pair + that can be converted is written in the marker's unit and reported with `converted: true`." required: - analyte - reason @@ -67867,6 +67900,16 @@ components: - type: "null" unit: type: string + converted: + type: boolean + sourceValue: + anyOf: + - type: number + - type: "null" + sourceUnit: + anyOf: + - type: string + - type: "null" referenceLow: anyOf: - type: number @@ -67928,6 +67971,9 @@ components: - value - valueText - unit + - converted + - sourceValue + - sourceUnit - referenceLow - referenceHigh - catalogReferenceLow @@ -67945,6 +67991,8 @@ components: - updatedAt 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. AiRunEnvelope: type: object properties: diff --git a/src/__tests__/written-outcome-response-consumer-guard.test.ts b/src/__tests__/written-outcome-response-consumer-guard.test.ts index 335b152f5..ed5468bf3 100644 --- a/src/__tests__/written-outcome-response-consumer-guard.test.ts +++ b/src/__tests__/written-outcome-response-consumer-guard.test.ts @@ -722,6 +722,9 @@ const OCR_EXCEPTIONS: Readonly> = { "inserted.value": OCR_ROW_EXCEPTION, "inserted.valueText": OCR_ROW_EXCEPTION, "inserted.unit": OCR_ROW_EXCEPTION, + "inserted.converted": OCR_ROW_EXCEPTION, + "inserted.sourceValue": OCR_ROW_EXCEPTION, + "inserted.sourceUnit": OCR_ROW_EXCEPTION, "inserted.referenceLow": OCR_ROW_EXCEPTION, "inserted.referenceHigh": OCR_ROW_EXCEPTION, "inserted.catalogReferenceLow": OCR_ROW_EXCEPTION, diff --git a/src/app/api/labs/[id]/route.ts b/src/app/api/labs/[id]/route.ts index fa2c7b326..6ef7f3d77 100644 --- a/src/app/api/labs/[id]/route.ts +++ b/src/app/api/labs/[id]/route.ts @@ -192,7 +192,15 @@ export const PUT = apiHandler( const data: Record = {}; if (d.panel !== undefined) data.panel = d.panel; if (d.analyte !== undefined) data.analyte = d.analyte; - if (d.value !== undefined) data.value = d.value; + if (d.value !== undefined) { + data.value = d.value; + // A number the person typed is no longer the conversion of what the + // report printed, so the "converted from" record would now be wrong. + if (existing.sourceValue !== null || existing.sourceUnit !== null) { + data.sourceValue = null; + data.sourceUnit = null; + } + } // v1.18.9 — qualitative edit. A row stays single-typed: editing `value` on a // qualitative row (or `valueText` on a numeric row) crosses the row's type // and is rejected below. An edit of the matching field updates in place. diff --git a/src/app/api/labs/ocr/commit/route.ts b/src/app/api/labs/ocr/commit/route.ts index 8a8647108..1066edb69 100644 --- a/src/app/api/labs/ocr/commit/route.ts +++ b/src/app/api/labs/ocr/commit/route.ts @@ -33,6 +33,7 @@ import { withIdempotency } from "@/lib/idempotency"; import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy"; import { emitDataArrival } from "@/lib/arrivals/emit-shared"; import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store"; +import { readingInMarkerUnit } from "@/lib/labs/reading-conversion"; import { sameLabUnit } from "@/lib/labs/unit-normalise"; import { parseReferenceRange } from "@/lib/labs/parse-reference-range"; import { serialiseLabResult } from "@/lib/labs/serialise"; @@ -75,7 +76,13 @@ async function isDuplicate( analyte: { equals: row.analyte.trim(), mode: "insensitive" }, takenAt: labReadingDaySearchRange(day), }, - select: { value: true, valueText: true, takenAt: true }, + select: { + value: true, + valueText: true, + sourceValue: true, + sourceUnit: true, + takenAt: true, + }, take: 100, }); const candidates = rows.filter((c) => labReadingDay(c.takenAt, tz) === day); @@ -83,6 +90,19 @@ async function isDuplicate( if (row.value !== undefined && c.value !== null && c.value === row.value) { return true; } + // A reading stored converted holds the value in the marker's unit, so the + // same scan committed again no longer equals it. What the report printed is + // kept beside it: the same number in the same unit is the same reading. + if ( + row.value !== undefined && + row.unit !== undefined && + c.sourceValue !== null && + c.sourceValue === row.value && + c.sourceUnit !== null && + sameLabUnit(c.sourceUnit, row.unit) + ) { + return true; + } if ( row.valueText !== undefined && c.valueText !== null && @@ -179,19 +199,40 @@ async function commitOcrRows(request: NextRequest) { panel: row.panel ?? null, }); - // The marker's unit is the one the reading is stored under, so a row that - // states another unit cannot be written: the number would be kept under a - // unit it is not in. The review screen marks such a row before Save; this - // is the backstop for a client that did not, and for a marker whose unit - // changed between the scan and the commit. A freshly minted marker adopted - // this row's unit, so it cannot disagree. - if ( - !isQualitative && - row.unit !== undefined && - !sameLabUnit(row.unit, biomarker.unit) - ) { - skipped.push({ analyte: row.analyte.trim(), reason: "unit_mismatch" }); - continue; + // The marker's unit is the one the reading is stored under. A row that + // states another unit is converted when the marker's analyte is known and + // the registry has the pair, with what the report printed kept beside it; + // otherwise it cannot be written: the number would be kept under a unit it + // is not in. The review screen marks such a row before Save; this is the + // backstop for a client that did not, and for a marker whose unit changed + // between the scan and the commit. A freshly minted marker adopted this + // row's unit, so it cannot disagree. + let storedValue = row.value ?? null; + let storedSourceLow = isQualitative ? null : sourceLow; + let storedSourceHigh = isQualitative ? null : sourceHigh; + let convertedFrom: { sourceValue: number; sourceUnit: string } | null = + null; + if (!isQualitative && row.value !== undefined) { + const reading = await readingInMarkerUnit(prisma, { + marker: biomarker, + value: row.value, + unit: row.unit, + sourceReferenceLow: storedSourceLow, + sourceReferenceHigh: storedSourceHigh, + }); + if (!reading.ok) { + skipped.push({ analyte: row.analyte.trim(), reason: "unit_mismatch" }); + continue; + } + storedValue = reading.value; + storedSourceLow = reading.sourceReferenceLow; + storedSourceHigh = reading.sourceReferenceHigh; + if (reading.converted) { + convertedFrom = { + sourceValue: reading.sourceValue, + sourceUnit: reading.sourceUnit, + }; + } } // Field-by-field — never spread the parsed row. The row stamps the resolved @@ -202,13 +243,15 @@ async function commitOcrRows(request: NextRequest) { biomarkerId: biomarker.id, panel: biomarker.panel, analyte: biomarker.name, - value: row.value ?? null, + value: storedValue, valueText: row.valueText ?? null, unit: biomarker.unit, + sourceValue: convertedFrom?.sourceValue ?? null, + sourceUnit: convertedFrom?.sourceUnit ?? null, referenceLow: biomarker.lowerBound, referenceHigh: biomarker.upperBound, - sourceReferenceLow: isQualitative ? null : sourceLow, - sourceReferenceHigh: isQualitative ? null : sourceHigh, + sourceReferenceLow: storedSourceLow, + sourceReferenceHigh: storedSourceHigh, sourceReferenceText: isQualitative ? null : sourceText, takenAt: row.takenAt, source: "OCR", diff --git a/src/app/api/labs/route.ts b/src/app/api/labs/route.ts index bd463890a..e18282a76 100644 --- a/src/app/api/labs/route.ts +++ b/src/app/api/labs/route.ts @@ -18,7 +18,7 @@ import { withIdempotency } from "@/lib/idempotency"; import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy"; import { emitDataArrival } from "@/lib/arrivals/emit-shared"; import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store"; -import { sameLabUnit } from "@/lib/labs/unit-normalise"; +import { readingInMarkerUnit } from "@/lib/labs/reading-conversion"; import { type ResolvedBiomarker, serialiseLabResult, @@ -232,6 +232,7 @@ async function postLabResult(request: NextRequest) { lowerBound: true, upperBound: true, panel: true, + analyteKey: true, }, }); if (!found) { @@ -253,30 +254,56 @@ async function postLabResult(request: NextRequest) { }); } - // The marker's unit is the one every reading is stored under, so a reading - // that states another unit cannot be written: the number would be kept and - // the label swapped, and nothing downstream could tell. Compared only when a - // unit was actually sent: on the `biomarkerId` path it may be omitted, and - // then the marker's unit is the stated one. A freshly minted marker adopted - // the reading's own unit, so it cannot disagree with it. - if ( - !isQualitative && - unit !== undefined && - !sameLabUnit(unit, biomarker.unit) - ) { - annotate({ - action: { name: "labs.create.unit-mismatch" }, - meta: { biomarkerId: biomarker.id }, + // The marker's unit is the one every reading is stored under. A reading that + // states another unit is converted when the marker's analyte is known and the + // registry has the pair (the value, and the report's own window with it, with + // what the report printed kept beside it); otherwise it cannot be written: + // the number would be kept and the label swapped, and nothing downstream + // could tell. Compared only when a unit was actually sent: on the + // `biomarkerId` path it may be omitted, and then the marker's unit is the + // stated one. A freshly minted marker adopted the reading's own unit, so it + // cannot disagree with it. + let storedValue = value ?? null; + let storedSourceLow = isQualitative ? null : (sourceReferenceLow ?? null); + let storedSourceHigh = isQualitative ? null : (sourceReferenceHigh ?? null); + let convertedFrom: { sourceValue: number; sourceUnit: string } | null = null; + if (!isQualitative && value !== undefined) { + const reading = await readingInMarkerUnit(prisma, { + marker: biomarker, + value, + unit, + sourceReferenceLow: storedSourceLow, + sourceReferenceHigh: storedSourceHigh, }); - return apiError( - `The reading is in ${unit}, but ${biomarker.name} is tracked in ${biomarker.unit}.`, - 422, - { - errorCode: "labs.unit.mismatch", - markerUnit: biomarker.unit, - readingUnit: unit, - }, - ); + if (!reading.ok) { + annotate({ + action: { name: "labs.create.unit-mismatch" }, + meta: { biomarkerId: biomarker.id, reason: reading.reason }, + }); + return apiError( + `The reading is in ${unit}, but ${biomarker.name} is tracked in ${biomarker.unit}.`, + 422, + { + errorCode: "labs.unit.mismatch", + markerUnit: biomarker.unit, + readingUnit: unit, + reason: reading.reason, + }, + ); + } + storedValue = reading.value; + storedSourceLow = reading.sourceReferenceLow; + storedSourceHigh = reading.sourceReferenceHigh; + if (reading.converted) { + convertedFrom = { + sourceValue: reading.sourceValue, + sourceUnit: reading.sourceUnit, + }; + annotate({ + action: { name: "labs.create.unit-converted" }, + meta: { biomarkerId: biomarker.id }, + }); + } } // Field-by-field assignment — never spread `parsed.data`. The row stamps the @@ -290,17 +317,20 @@ async function postLabResult(request: NextRequest) { panel: biomarker.panel, analyte: biomarker.name, // Exactly one of value / valueText is set (the Zod XOR refine). - value: value ?? null, + value: storedValue, valueText: valueText ?? null, unit: biomarker.unit, + // What the report printed, when the value above is a conversion of it. + sourceValue: convertedFrom?.sourceValue ?? null, + sourceUnit: convertedFrom?.sourceUnit ?? null, referenceLow: biomarker.lowerBound, referenceHigh: biomarker.upperBound, // The window this reading's own report printed, kept beside the catalog // stamp rather than folded into it: it governs THIS reading's verdict, // and the catalog band stays the net for every reading without one. A // qualitative reading has no window to record. - sourceReferenceLow: isQualitative ? null : (sourceReferenceLow ?? null), - sourceReferenceHigh: isQualitative ? null : (sourceReferenceHigh ?? null), + sourceReferenceLow: storedSourceLow, + sourceReferenceHigh: storedSourceHigh, sourceReferenceText: isQualitative ? null : (sourceReferenceText ?? null), takenAt, // v1.25 (iOS #36) — provenance: the on-device-OCR path posts diff --git a/src/components/labs/__tests__/lab-biomarker-detail.test.tsx b/src/components/labs/__tests__/lab-biomarker-detail.test.tsx index 17e2cbc4b..09995ac6a 100644 --- a/src/components/labs/__tests__/lab-biomarker-detail.test.tsx +++ b/src/components/labs/__tests__/lab-biomarker-detail.test.tsx @@ -68,6 +68,9 @@ function reading(id: string, value: number, takenAt: string): LabResultDto { value, valueText: null, unit: "mg/dL", + converted: false, + sourceValue: null, + sourceUnit: null, referenceLow: 0, referenceHigh: 100, catalogReferenceLow: 0, diff --git a/src/components/labs/types.ts b/src/components/labs/types.ts index b72b4d0df..60a1c2bd7 100644 --- a/src/components/labs/types.ts +++ b/src/components/labs/types.ts @@ -15,6 +15,14 @@ export interface LabResultDto { /** v1.18.9 — qualitative result text ("negativ" / …); null for a numeric row. */ valueText: string | null; unit: string; + /** + * True when the stored number was converted into the marker's unit from + * another one; `sourceValue` and `sourceUnit` are then what the report + * printed. A reading stored as stated has `converted: false` and two nulls. + */ + converted: boolean; + sourceValue: number | null; + sourceUnit: string | null; /** * The window this reading is judged against and the one `rangeStatus` was * computed from: the range its own report printed when it carries one, the diff --git a/src/lib/documents/__tests__/commit.test.ts b/src/lib/documents/__tests__/commit.test.ts index 5563913eb..591ebae47 100644 --- a/src/lib/documents/__tests__/commit.test.ts +++ b/src/lib/documents/__tests__/commit.test.ts @@ -44,22 +44,25 @@ beforeEach(() => { describe("commitApprovedFact — OBSERVATION unit integrity", () => { it("rejects a numeric observation whose stated unit differs from an existing marker's unit", async () => { + // A pair the registry has no conversion for: ferritin in pmol/L against a + // marker stored in ng/mL (protein molecular weights vary by source). vi.mocked(decryptFactData).mockReturnValue({ - label: "Glucose", - value: 6.1, - unit: "mmol/L", + label: "Ferritin", + value: 450, + unit: "pmol/L", referenceLow: null, referenceHigh: null, effectiveDate: "2025-01-01", } as never); - // Existing marker stored in mg/dL — the resolver returns it as-is. + // Existing marker stored in ng/mL — the resolver returns it as-is. vi.mocked(resolveOrMintBiomarker).mockResolvedValue({ id: "bm-1", - name: "Glucose", - unit: "mg/dL", - lowerBound: 70, - upperBound: 100, + name: "Ferritin", + unit: "ng/mL", + lowerBound: 30, + upperBound: 400, panel: null, + analyteKey: null, }); await expect( @@ -71,6 +74,69 @@ describe("commitApprovedFact — OBSERVATION unit integrity", () => { expect(prisma.labResult.create).not.toHaveBeenCalled(); }); + it("converts a unit the marker's analyte can reach, keeping what the document stated", async () => { + vi.mocked(decryptFactData).mockReturnValue({ + label: "Glucose", + value: 6.1, + unit: "mmol/L", + referenceLow: 3.9, + referenceHigh: 5.6, + effectiveDate: "2025-01-01", + } as never); + vi.mocked(resolveOrMintBiomarker).mockResolvedValue({ + id: "bm-1", + name: "Glucose", + unit: "mg/dL", + lowerBound: 70, + upperBound: 100, + panel: null, + analyteKey: "fasting-glucose", + }); + vi.mocked(prisma.labResult.create).mockResolvedValue({ + id: "lr-conv", + } as never); + + const ref = await commitApprovedFact("user-1", observationFact()); + expect(ref).toEqual({ recordType: "labResult", recordId: "lr-conv" }); + const data = vi.mocked(prisma.labResult.create).mock.calls[0]![0].data; + expect(data.value).toBeCloseTo(6.1 * 18.0182, 9); + expect(data.unit).toBe("mg/dL"); + expect(data.sourceValue).toBe(6.1); + expect(data.sourceUnit).toBe("mmol/L"); + // The report's own window moved with the value. + expect(data.sourceReferenceLow).toBeCloseTo(3.9 * 18.0182, 9); + expect(data.sourceReferenceHigh).toBeCloseTo(5.6 * 18.0182, 9); + }); + + it("stores a reading in the marker's own unit without a conversion record", async () => { + vi.mocked(decryptFactData).mockReturnValue({ + label: "Glucose", + value: 95, + unit: "mg/dL", + referenceLow: null, + referenceHigh: null, + effectiveDate: "2025-01-01", + } as never); + vi.mocked(resolveOrMintBiomarker).mockResolvedValue({ + id: "bm-1", + name: "Glucose", + unit: "mg/dL", + lowerBound: null, + upperBound: null, + panel: null, + analyteKey: "fasting-glucose", + }); + vi.mocked(prisma.labResult.create).mockResolvedValue({ + id: "lr-same", + } as never); + + await commitApprovedFact("user-1", observationFact()); + const data = vi.mocked(prisma.labResult.create).mock.calls[0]![0].data; + expect(data.value).toBe(95); + expect(data.sourceValue).toBeNull(); + expect(data.sourceUnit).toBeNull(); + }); + it("commits when the stated unit is the marker's unit spelled another way", async () => { vi.mocked(decryptFactData).mockReturnValue({ label: "Glucose", @@ -87,6 +153,7 @@ describe("commitApprovedFact — OBSERVATION unit integrity", () => { lowerBound: 70, upperBound: 100, panel: null, + analyteKey: null, }); vi.mocked(prisma.labResult.create).mockResolvedValue({ id: "lr-1", @@ -124,6 +191,7 @@ describe("commitApprovedFact — OBSERVATION unit integrity", () => { lowerBound: null, upperBound: null, panel: null, + analyteKey: null, }); vi.mocked(prisma.labResult.create).mockResolvedValue({ id: "lr-2", diff --git a/src/lib/documents/commit.ts b/src/lib/documents/commit.ts index 4645acc0f..a29b4ddc6 100644 --- a/src/lib/documents/commit.ts +++ b/src/lib/documents/commit.ts @@ -28,7 +28,7 @@ import { invalidateUserHealthScore } from "@/lib/cache/invalidate"; import { emitDataArrival } from "@/lib/arrivals/emit-shared"; import { decryptFactData } from "@/lib/documents/store"; import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store"; -import { sameLabUnit } from "@/lib/labs/unit-normalise"; +import { readingInMarkerUnit } from "@/lib/labs/reading-conversion"; import { isUnreadableRange, parseReferenceRange, @@ -128,22 +128,45 @@ async function commitObservation( referenceHigh: isQualitative ? null : sourceHigh, panel: null, }); - // Fail closed on a unit mismatch against an EXISTING marker. A freshly - // minted marker adopts the document's unit, so this only bites when the - // stated unit disagrees with the catalog's authoritative one (e.g. a - // document states "6.1 mmol/L" against a marker stored in mg/dL). Writing - // the stated number under the catalog's unit would corrupt the reading AND - // break the "transcribe verbatim" contract — so reject the fact and let the - // user reconcile on the review screen, exactly like the `unitRequired` gate. + // A unit that differs from an EXISTING marker's is converted when the + // marker's analyte is known and the registry has the pair (a document states + // "6.1 mmol/L" against a marker stored in mg/dL), keeping what the document + // stated beside the stored number. Otherwise it fails closed: writing the + // stated number under the catalog's unit would corrupt the reading AND break + // the "transcribe verbatim" contract — so reject the fact and let the user + // reconcile on the review screen, exactly like the `unitRequired` gate. A + // freshly minted marker adopts the document's unit, so it cannot disagree. // Spelling is compared through the shared normaliser, the same one the // manual and scan paths use: `ug/L` is `µg/L`, but `MIU/L` is not `mIU/L`. + let storedValue: number | null = isQualitative + ? null + : (data.value as number); + let storedSourceLow = isQualitative ? null : sourceLow; + let storedSourceHigh = isQualitative ? null : sourceHigh; + let convertedFrom: { sourceValue: number; sourceUnit: string } | null = null; if (!isQualitative) { - if (!sameLabUnit(data.unit as string, biomarker.unit)) { + const reading = await readingInMarkerUnit(prisma, { + marker: biomarker, + value: data.value as number, + unit: data.unit as string, + sourceReferenceLow: storedSourceLow, + sourceReferenceHigh: storedSourceHigh, + }); + if (!reading.ok) { throw new FactCommitError( "observation.unitMismatch", `The stated unit (${(data.unit as string).trim()}) does not match the saved unit for ${biomarker.name} (${biomarker.unit || "none"}). Reconcile the unit before saving.`, ); } + storedValue = reading.value; + storedSourceLow = reading.sourceReferenceLow; + storedSourceHigh = reading.sourceReferenceHigh; + if (reading.converted) { + convertedFrom = { + sourceValue: reading.sourceValue, + sourceUnit: reading.sourceUnit, + }; + } } const created = await prisma.labResult.create({ data: { @@ -151,15 +174,17 @@ async function commitObservation( biomarkerId: biomarker.id, panel: biomarker.panel, analyte: biomarker.name, - value: isQualitative ? null : (data.value as number), + value: storedValue, valueText: isQualitative ? data.valueText : null, unit: biomarker.unit, + sourceValue: convertedFrom?.sourceValue ?? null, + sourceUnit: convertedFrom?.sourceUnit ?? null, referenceLow: biomarker.lowerBound, referenceHigh: biomarker.upperBound, // The report's own window for THIS reading. A qualitative result has no // window to record. - sourceReferenceLow: isQualitative ? null : sourceLow, - sourceReferenceHigh: isQualitative ? null : sourceHigh, + sourceReferenceLow: storedSourceLow, + sourceReferenceHigh: storedSourceHigh, sourceReferenceText: isQualitative ? null : sourceText, takenAt: statedDateOrNow(data.effectiveDate), source: "DOCUMENT", diff --git a/src/lib/labs/__tests__/reading-conversion.test.ts b/src/lib/labs/__tests__/reading-conversion.test.ts new file mode 100644 index 000000000..1c8823b30 --- /dev/null +++ b/src/lib/labs/__tests__/reading-conversion.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it, vi } from "vitest"; + +import { readingInMarkerUnit, type ReadingMarker } from "../reading-conversion"; + +/** A client whose `updateMany` records what the fill wrote. */ +function client(current: string | null = null) { + return { + biomarker: { + updateMany: vi.fn(async () => ({ count: 1 })), + findUnique: vi.fn(async () => ({ analyteKey: current })), + }, + } as never; +} + +const glucose: ReadingMarker = { + id: "bm-1", + name: "Glucose", + unit: "mg/dL", + analyteKey: "fasting-glucose", +}; + +const read = ( + marker: ReadingMarker, + value: number, + unit: string | undefined, + low: number | null = null, + high: number | null = null, + c = client(), +) => + readingInMarkerUnit(c, { + marker, + value, + unit, + sourceReferenceLow: low, + sourceReferenceHigh: high, + }); + +describe("a reading in the marker's own unit", () => { + it("is stored as stated, with its window untouched", async () => { + expect(await read(glucose, 95, "mg/dL", 70, 100)).toEqual({ + ok: true, + converted: false, + value: 95, + sourceReferenceLow: 70, + sourceReferenceHigh: 100, + }); + }); + + it("is the same when the unit is spelled another way or not sent", async () => { + expect(await read(glucose, 95, " mg/DL ")).toMatchObject({ + ok: true, + converted: false, + value: 95, + }); + expect(await read(glucose, 95, undefined)).toMatchObject({ + ok: true, + converted: false, + value: 95, + }); + }); + + it("does not touch the database", async () => { + const c = client(); + await read({ ...glucose, analyteKey: null }, 95, "mg/dL", null, null, c); + expect( + (c as never as { biomarker: { updateMany: unknown } }).biomarker + .updateMany, + ).not.toHaveBeenCalled(); + }); +}); + +describe("a reading in another unit", () => { + it("is converted into the marker's unit, keeping what the report printed", async () => { + const result = await read(glucose, 5.6, "mmol/L"); + expect(result).toMatchObject({ + ok: true, + converted: true, + sourceValue: 5.6, + sourceUnit: "mmol/L", + }); + if (!result.ok) return; + expect(result.value).toBeCloseTo(5.6 * 18.0182, 9); + }); + + it("stores full precision and trims the printed unit", async () => { + const result = await read(glucose, 5.6, " mmol/L "); + if (!result.ok || !result.converted) throw new Error("not converted"); + expect(result.sourceUnit).toBe("mmol/L"); + expect(result.value).not.toBe(Math.round(result.value)); + }); + + it("moves the report's own window with the value", async () => { + const result = await read(glucose, 5.6, "mmol/L", 3.9, 5.6); + if (!result.ok) throw new Error("refused"); + expect(result.sourceReferenceLow).toBeCloseTo(3.9 * 18.0182, 9); + expect(result.sourceReferenceHigh).toBeCloseTo(5.6 * 18.0182, 9); + expect(result.sourceReferenceLow!).toBeLessThan( + result.sourceReferenceHigh!, + ); + }); + + it("leaves an open bound open", async () => { + const result = await read(glucose, 5, "mmol/L", null, 5.6); + if (!result.ok) throw new Error("refused"); + expect(result.sourceReferenceLow).toBeNull(); + expect(result.sourceReferenceHigh).toBeCloseTo(5.6 * 18.0182, 9); + }); + + it("converts an affine pair: 47.5 mmol/mol is 6.5 % HbA1c", async () => { + const hba1c: ReadingMarker = { + id: "bm-2", + name: "HbA1c", + unit: "%", + analyteKey: "hba1c", + }; + const result = await read(hba1c, 47.5, "mmol/mol", 20, 42); + if (!result.ok) throw new Error("refused"); + expect(result.value).toBeCloseTo(6.5, 1); + expect(result.sourceReferenceLow!).toBeLessThan( + result.sourceReferenceHigh!, + ); + }); + + it("converts by scale alone, for a marker with no analyte", async () => { + const unknown: ReadingMarker = { + id: "bm-3", + name: "Some marker", + unit: "mg/dL", + analyteKey: null, + }; + const result = await read(unknown, 1, "g/L"); + expect(result).toMatchObject({ ok: true, converted: true, value: 100 }); + }); +}); + +describe("the analyte is filled on first use", () => { + it("links a marker that had no key yet, then converts", async () => { + const c = client(); + const result = await read( + { id: "bm-1", name: "Blutzucker", unit: "mg/dL", analyteKey: null }, + 5.6, + "mmol/L", + null, + null, + c, + ); + expect(result).toMatchObject({ ok: true, converted: true }); + const updateMany = ( + c as never as { + biomarker: { updateMany: ReturnType }; + } + ).biomarker.updateMany; + expect(updateMany).toHaveBeenCalledWith({ + where: { id: "bm-1", analyteKey: null }, + data: { analyteKey: "fasting-glucose" }, + }); + }); +}); + +describe("a refusal", () => { + it("is returned for a marker with no analyte and a different kind of unit", async () => { + const free: ReadingMarker = { + id: "bm-4", + name: "My own marker", + unit: "mg/dL", + analyteKey: null, + }; + expect(await read(free, 5.6, "mmol/L")).toEqual({ + ok: false, + reason: "unknown_analyte", + }); + }); + + it("is returned for a pair the analyte's entry does not reach", async () => { + expect(await read(glucose, 5.6, "%")).toEqual({ + ok: false, + reason: "unknown_unit", + }); + }); + + it("is returned for a value that is not finite", async () => { + expect(await read(glucose, Number.NaN, "mmol/L")).toEqual({ + ok: false, + reason: "not_finite", + }); + }); +}); diff --git a/src/lib/labs/__tests__/resolve-or-mint-biomarker.test.ts b/src/lib/labs/__tests__/resolve-or-mint-biomarker.test.ts index e11976666..d6b1d3719 100644 --- a/src/lib/labs/__tests__/resolve-or-mint-biomarker.test.ts +++ b/src/lib/labs/__tests__/resolve-or-mint-biomarker.test.ts @@ -38,6 +38,7 @@ describe("resolveOrMintBiomarker", () => { lowerBound: null, upperBound: 100, panel: "lipids", + analyteKey: null, }); const out = await resolveOrMintBiomarker("u1", { @@ -65,6 +66,7 @@ describe("resolveOrMintBiomarker", () => { lowerBound: null, upperBound: 116, panel: "lipids", + analyteKey: null, }); const out = await resolveOrMintBiomarker("u1", baseInput); @@ -90,6 +92,7 @@ describe("resolveOrMintBiomarker", () => { lowerBound: 30, upperBound: 400, panel: null, + analyteKey: null, }); await resolveOrMintBiomarker("u1", { diff --git a/src/lib/labs/biomarker-store.ts b/src/lib/labs/biomarker-store.ts index d987fc8ba..c6ca11e3a 100644 --- a/src/lib/labs/biomarker-store.ts +++ b/src/lib/labs/biomarker-store.ts @@ -65,6 +65,7 @@ export interface MintedBiomarker { lowerBound: number | null; upperBound: number | null; panel: string | null; + analyteKey: string | null; } /** @@ -102,6 +103,7 @@ export async function resolveOrMintBiomarker( lowerBound: true, upperBound: true, panel: true, + analyteKey: true, } as const; // Case-insensitive match on the catalog identity. Postgres is the store; diff --git a/src/lib/labs/reading-conversion.ts b/src/lib/labs/reading-conversion.ts new file mode 100644 index 000000000..105650d4d --- /dev/null +++ b/src/lib/labs/reading-conversion.ts @@ -0,0 +1,105 @@ +/** + * A lab reading, written in its marker's unit. + * + * Every reading is stored under the unit of its marker. A reading that states + * the same unit (spelling aside) goes in as it is. One that states another is + * converted when the marker's analyte is known and the registry has the pair, + * keeping what the report printed beside it (`sourceValue`, `sourceUnit`) so a + * number nobody typed can always be traced back. Anything else is refused, + * exactly as before: the number is never kept under a unit it is not in. + * + * The report's own reference window is in the unit the report printed, so it is + * converted with the value; a verdict that compared a converted value with an + * unconverted window would be wrong. Full precision is stored, rounding is for + * display. + */ +import type { PrismaClient } from "@/generated/prisma/client"; + +import { ensureAnalyteKey } from "./analyte-key-store"; +import { + convertLabRange, + convertLabValue, + type ConversionRefusal, +} from "./unit-conversion/convert"; +import { sameLabUnit } from "./unit-normalise"; + +export interface ReadingMarker { + id: string; + name: string; + unit: string; + analyteKey?: string | null; +} + +export type ReadingInMarkerUnit = + | { + ok: true; + converted: false; + value: number; + sourceReferenceLow: number | null; + sourceReferenceHigh: number | null; + } + | { + ok: true; + converted: true; + value: number; + sourceReferenceLow: number | null; + sourceReferenceHigh: number | null; + /** The value as the report printed it. */ + sourceValue: number; + /** The unit as the report printed it, trimmed. */ + sourceUnit: string; + } + | { ok: false; reason: ConversionRefusal }; + +export async function readingInMarkerUnit( + client: Pick, + input: { + marker: ReadingMarker; + value: number; + /** The unit the reading states; undefined means the marker's own. */ + unit: string | undefined; + sourceReferenceLow: number | null; + sourceReferenceHigh: number | null; + }, +): Promise { + const { marker, value, unit, sourceReferenceLow, sourceReferenceHigh } = + input; + if (unit === undefined || sameLabUnit(unit, marker.unit)) { + return { + ok: true, + converted: false, + value, + sourceReferenceLow, + sourceReferenceHigh, + }; + } + + const analyteKey = await ensureAnalyteKey(client, marker); + const converted = convertLabValue({ + analyteKey, + value, + from: unit, + to: marker.unit, + }); + if (!converted.ok) return { ok: false, reason: converted.reason }; + + // The printed window is in the printed unit; move it with the value. + const range = convertLabRange({ + analyteKey, + low: sourceReferenceLow, + high: sourceReferenceHigh, + from: unit, + to: marker.unit, + }); + if (!range.ok) return { ok: false, reason: range.reason }; + + return { + ok: true, + converted: true, + value: converted.value, + sourceReferenceLow: range.low, + sourceReferenceHigh: range.high, + sourceValue: value, + sourceUnit: unit.trim(), + }; +} diff --git a/src/lib/labs/serialise.ts b/src/lib/labs/serialise.ts index 2dc6f6ced..cc73c7bec 100644 --- a/src/lib/labs/serialise.ts +++ b/src/lib/labs/serialise.ts @@ -53,6 +53,8 @@ export interface ResolvedBiomarker { lowerBound: number | null; upperBound: number | null; panel: string | null; + /** Which analyte the marker is (unit-conversion registry key), if known. */ + analyteKey?: string | null; } /** The lab-result row shape the resolver reads (Prisma row subset). */ @@ -74,6 +76,12 @@ export interface LabRow { takenAt: Date; source: string; biomarkerId: string | null; + /** + * 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; noteEncrypted: Uint8Array | null; createdAt: Date; updatedAt: Date; @@ -179,6 +187,7 @@ export function serialiseLabResult( biomarker?: ResolvedBiomarker | null, ) { const resolved = resolveLabFields(row, biomarker); + const conversion = conversionFields(row); return { id: row.id, biomarkerId: row.biomarkerId, @@ -187,6 +196,9 @@ export function serialiseLabResult( value: row.value, valueText: row.valueText, unit: resolved.unit, + converted: conversion.converted, + sourceValue: conversion.sourceValue, + sourceUnit: conversion.sourceUnit, referenceLow: resolved.referenceLow, referenceHigh: resolved.referenceHigh, catalogReferenceLow: resolved.catalogReferenceLow, @@ -210,6 +222,20 @@ export function serialiseLabResult( }; } +/** + * Whether the stored number is a conversion, and what the report printed. A + * reading stored as stated reports `converted: false` and two nulls. + */ +function conversionFields(row: Pick) { + const sourceValue = row.sourceValue ?? null; + const sourceUnit = row.sourceUnit ?? null; + return { + converted: sourceValue !== null && sourceUnit !== null, + sourceValue, + sourceUnit, + }; +} + /** * Serialise a lab row to the detail DTO — same as the list DTO but carries the * decrypted `note` instead of the `hasNote` flag. @@ -220,6 +246,7 @@ export function serialiseLabResultDetail( note: string | null, ) { const resolved = resolveLabFields(row, biomarker); + const conversion = conversionFields(row); return { id: row.id, biomarkerId: row.biomarkerId, @@ -228,6 +255,9 @@ export function serialiseLabResultDetail( value: row.value, valueText: row.valueText, unit: resolved.unit, + converted: conversion.converted, + sourceValue: conversion.sourceValue, + sourceUnit: conversion.sourceUnit, referenceLow: resolved.referenceLow, referenceHigh: resolved.referenceHigh, catalogReferenceLow: resolved.catalogReferenceLow, diff --git a/src/lib/openapi/routes/labs.ts b/src/lib/openapi/routes/labs.ts index ca0b92046..bedfc0d09 100644 --- a/src/lib/openapi/routes/labs.ts +++ b/src/lib/openapi/routes/labs.ts @@ -70,6 +70,9 @@ const labResultRow = z value: z.number().nullable(), valueText: z.string().nullable(), unit: z.string(), + converted: z.boolean(), + sourceValue: z.number().nullable(), + sourceUnit: z.string().nullable(), referenceLow: z.number().nullable(), referenceHigh: z.number().nullable(), catalogReferenceLow: z.number().nullable(), @@ -89,7 +92,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. 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. 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`.", }); const labResultDetail = labResultRow @@ -164,7 +167,7 @@ export const labsPaths: NonNullable = { ...stdResponses, "422": { description: - "Nothing was written. `meta.errorCode` = `labs.create.invalid` when the body failed validation (every issue under `details.issues`), or `labs.unit.mismatch` when a numeric reading states a `unit` other than the one its marker is tracked in (`meta.markerUnit`, `meta.readingUnit`). A unit is compared only when one is sent; with a `biomarkerId` it may be omitted and the marker's unit is then the stated one. Spelling is forgiven (`mmol/l`, `ug/L` for `µg/L`); a different unit is refused, never relabelled.", + "Nothing was written. `meta.errorCode` = `labs.create.invalid` when the body failed validation (every issue under `details.issues`), or `labs.unit.mismatch` when a numeric reading states a `unit` other than the one its marker is tracked in AND the pair cannot be converted (`meta.markerUnit`, `meta.readingUnit`, `meta.reason` = `unknown_analyte` | `unknown_unit` | `not_finite`). A unit is compared only when one is sent; with a `biomarkerId` it may be omitted and the marker's unit is then the stated one. Spelling is forgiven (`mmol/l`, `ug/L` for `µg/L`); a different unit is converted when the marker's analyte is known and the pair is in the unit-conversion registry (the 201 response then carries `converted: true`), and refused otherwise, never relabelled.", content: { "application/json": { schema: errorEnvelope } }, }, ...recordWriteRateLimitResponse, diff --git a/src/lib/openapi/routes/ocr.ts b/src/lib/openapi/routes/ocr.ts index 9de42ea6d..94ffe0c80 100644 --- a/src/lib/openapi/routes/ocr.ts +++ b/src/lib/openapi/routes/ocr.ts @@ -115,6 +115,9 @@ const committedRow = z value: z.number().nullable(), valueText: z.string().nullable(), unit: z.string(), + converted: z.boolean(), + sourceValue: z.number().nullable(), + sourceUnit: z.string().nullable(), referenceLow: z.number().nullable(), referenceHigh: z.number().nullable(), catalogReferenceLow: z.number().nullable(), @@ -134,7 +137,7 @@ const committedRow = z .meta({ id: "OcrCommittedLabResult", description: - "A written reading. Its reference window resolves exactly as it does on `/api/labs` — see `LabResult`.", + "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.", }); const commitResponse = z @@ -145,7 +148,7 @@ const commitResponse = z analyte: z.string(), reason: z.enum(["duplicate", "unit_mismatch"]).meta({ description: - "`duplicate`: the row matched a live reading at commit time. `unit_mismatch`: the row states a unit other than the one its marker is tracked in, so it was not written.", + "`duplicate`: the row matched a live reading at commit time. `unit_mismatch`: the row states a unit other than the one its marker is tracked in and the pair cannot be converted, so it was not written. A pair that can be converted is written in the marker's unit and reported with `converted: true`.", }), }), ), diff --git a/tests/integration/lab-unit-mismatch.test.ts b/tests/integration/lab-unit-mismatch.test.ts index 0596330b7..06a926202 100644 --- a/tests/integration/lab-unit-mismatch.test.ts +++ b/tests/integration/lab-unit-mismatch.test.ts @@ -9,6 +9,13 @@ * not: a different spelling of the same unit, a qualitative reading, a * reading with no unit on the structured path and a brand-new marker must all * still write. + * + * "Another unit" is refused only where the pair cannot be converted. Ferritin + * in pmol/L against a marker kept in ng/mL is the standing example: protein + * molecular weights vary by source, so the registry has no conversion and the + * reading stays refused. Where the marker's analyte is known and the pair is in + * the registry (glucose in mmol/L against mg/dL) the reading is converted + * instead; that is `labs-unit-conversion.test.ts`. */ import { NextRequest } from "next/server"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -74,6 +81,19 @@ async function seedGlucose() { }); } +/** A marker whose analyte the registry has no conversion for. */ +async function seedFerritin() { + return getPrismaClient().biomarker.create({ + data: { + userId: USER_ID, + name: "Ferritin", + unit: "ng/mL", + lowerBound: 30, + upperBound: 400, + }, + }); +} + function jsonReq(path: string, body: unknown): NextRequest { return new NextRequest(`http://localhost${path}`, { method: "POST", @@ -103,27 +123,28 @@ const labCount = () => describe("POST /api/labs", () => { it("refuses a reading in another unit than its marker's and writes nothing", async () => { - await seedGlucose(); + await seedFerritin(); const { status, body } = await postLab({ - analyte: "Glucose", - value: 5.6, - unit: "mmol/L", + analyte: "Ferritin", + value: 450, + unit: "pmol/L", }); expect(status).toBe(422); expect(body.meta).toMatchObject({ errorCode: "labs.unit.mismatch", - markerUnit: "mg/dL", - readingUnit: "mmol/L", + markerUnit: "ng/mL", + readingUnit: "pmol/L", + reason: "unknown_analyte", }); expect(await labCount()).toBe(0); }); it("refuses it on the structured path too, when a unit is sent", async () => { - const marker = await seedGlucose(); + const marker = await seedFerritin(); const { status, body } = await postLab({ biomarkerId: marker.id, - value: 5.6, - unit: "mmol/L", + value: 450, + unit: "pmol/L", }); expect(status).toBe(422); expect(body.meta.errorCode).toBe("labs.unit.mismatch"); @@ -166,30 +187,30 @@ describe("POST /api/labs", () => { it("lets a new marker adopt the unit of its first reading", async () => { const { status, body } = await postLab({ - analyte: "Creatinine", + analyte: "Ferritin", value: 80, - unit: "µmol/L", + unit: "ng/mL", }); expect(status).toBe(201); - expect(body.data.unit).toBe("µmol/L"); + expect(body.data.unit).toBe("ng/mL"); // …and then holds the next reading in another unit to it. const next = await postLab({ - analyte: "Creatinine", - value: 0.9, - unit: "mg/dL", + analyte: "Ferritin", + value: 180, + unit: "pmol/L", }); expect(next.status).toBe(422); expect(await labCount()).toBe(1); }); it("never judges the refused value against the marker's range", async () => { - // The case from the report: 5.6 mmol/L is an ordinary glucose, and 5.6 - // mg/dL would read as far below range. - await seedGlucose(); - await postLab({ analyte: "Glucose", value: 5.6, unit: "mmol/L" }); + // The case from the report, for a pair that is refused: the number must + // never be kept under a unit it is not in and judged against that band. + await seedFerritin(); + await postLab({ analyte: "Ferritin", value: 450, unit: "pmol/L" }); const { GET } = await import("@/app/api/labs/route"); const res = await GET( - new NextRequest("http://localhost/api/labs?analyte=Glucose"), + new NextRequest("http://localhost/api/labs?analyte=Ferritin"), ); expect((await res.json()).data.results).toEqual([]); }); @@ -197,30 +218,30 @@ describe("POST /api/labs", () => { describe("POST /api/labs/ocr/commit", () => { it("writes the matching row, skips the one in another unit, and reports a partial outcome", async () => { - await seedGlucose(); + await seedFerritin(); const { status, body } = await commitRows([ - { analyte: "Glucose", value: 95, unit: "mg/dL" }, + { analyte: "Ferritin", value: 95, unit: "ng/mL" }, { - analyte: "Glucose", - value: 5.6, - unit: "mmol/L", + analyte: "Ferritin", + value: 450, + unit: "pmol/L", takenAt: "2026-08-10T12:00:00.000Z", }, ]); expect(status).toBe(200); expect(body.data.inserted).toHaveLength(1); - expect(body.data.inserted[0]).toMatchObject({ value: 95, unit: "mg/dL" }); + expect(body.data.inserted[0]).toMatchObject({ value: 95, unit: "ng/mL" }); expect(body.data.skipped).toEqual([ - { analyte: "Glucose", reason: "unit_mismatch" }, + { analyte: "Ferritin", reason: "unit_mismatch" }, ]); expect(body.data.outcome).toBe("partial"); expect(await labCount()).toBe(1); }); it("reports a failed outcome when every row is in another unit", async () => { - await seedGlucose(); + await seedFerritin(); const { body } = await commitRows([ - { analyte: "Glucose", value: 5.6, unit: "mmol/L" }, + { analyte: "Ferritin", value: 450, unit: "pmol/L" }, ]); expect(body.data.inserted).toHaveLength(0); expect(body.data.outcome).toBe("failed"); diff --git a/tests/integration/labs-unit-conversion.test.ts b/tests/integration/labs-unit-conversion.test.ts new file mode 100644 index 000000000..b6199c01d --- /dev/null +++ b/tests/integration/labs-unit-conversion.test.ts @@ -0,0 +1,316 @@ +/** + * A lab reading in another unit is converted into its marker's unit (#1095). + * + * Until now a reading that stated a unit other than its marker's was refused. + * When the marker's analyte is known and the registry has the pair, the three + * write paths now convert it, keep what the report printed beside it, and move + * the report's own reference window with the value. Against a real Postgres: + * + * - POST /api/labs converts, stores full precision and the printed value and + * unit, converts the printed window, and says so in the response; + * - a pair that cannot be converted is still the 422 it always was, now with + * the reason; + * - the verdict is judged against the converted window, so a value in range in + * the printed unit is in range after conversion; + * - the OCR commit converts, and the same scan committed again is a duplicate + * (the stored number is converted, so the printed one is what matches); + * - editing the value of a converted reading clears the conversion record; + * - the list returns the fields. + */ +import { NextRequest } from "next/server"; +import { beforeAll, describe, expect, it, vi } from "vitest"; + +import { cookieJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const prisma = getPrismaClient(); +const USER = "lab-conversion-user"; +const MGDL_PER_MMOL = 18.0182; + +beforeAll(async () => { + await truncateAllTables(prisma); + await prisma.user.create({ data: { id: USER, username: USER } }); + // A catalog marker the user tracks in mg/dL, with a catalog band. + await prisma.biomarker.create({ + data: { + id: "bm-glucose", + userId: USER, + name: "Glucose", + unit: "mg/dL", + lowerBound: 70, + upperBound: 100, + }, + }); + // A marker the registry knows nothing about. + await prisma.biomarker.create({ + data: { + id: "bm-free", + userId: USER, + name: "My own marker", + unit: "mg/dL", + }, + }); +}); + +async function call( + route: "labs" | "ocr" | "one", + method: "GET" | "POST" | "PUT", + body?: unknown, + id?: string, +): Promise<{ + status: number; + json: { data?: T; meta?: Record }; +}> { + const s = await prisma.session.create({ + data: { userId: USER, expiresAt: new Date(Date.now() + 3_600_000) }, + }); + cookieJar.clear(); + cookieJar.set("healthlog_session", s.id); + const url = + route === "labs" + ? "http://localhost/api/labs" + : route === "ocr" + ? "http://localhost/api/labs/ocr/commit" + : `http://localhost/api/labs/${id}`; + const init = { + method, + headers: { "content-type": "application/json" }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }; + const req = new NextRequest(url, init); + let res: Response; + if (route === "labs") { + const mod = await import("@/app/api/labs/route"); + res = await (method === "GET" ? mod.GET(req) : mod.POST(req)); + } else if (route === "ocr") { + const mod = await import("@/app/api/labs/ocr/commit/route"); + res = await mod.POST(req); + } else { + const mod = await import("@/app/api/labs/[id]/route"); + res = await (method === "PUT" + ? mod.PUT(req, { params: Promise.resolve({ id: id! }) }) + : mod.GET(req, { params: Promise.resolve({ id: id! }) })); + } + return { status: res.status, json: (await res.json()) as never }; +} + +type Dto = { + id: string; + value: number; + unit: string; + converted: boolean; + sourceValue: number | null; + sourceUnit: string | null; + referenceLow: number | null; + referenceHigh: number | null; + sourceReferenceLow: number | null; + sourceReferenceHigh: number | null; + rangeStatus: string; +}; + +describe("POST /api/labs", () => { + it("converts a reading in another unit and keeps what the report printed", async () => { + const { status, json } = await call("labs", "POST", { + biomarkerId: "bm-glucose", + value: 5.6, + unit: "mmol/L", + sourceReferenceLow: 3.9, + sourceReferenceHigh: 6.0, + takenAt: "2026-05-01T08:00:00.000Z", + }); + expect(status).toBe(201); + const dto = json.data!; + expect(dto.converted).toBe(true); + expect(dto.unit).toBe("mg/dL"); + expect(dto.value).toBeCloseTo(5.6 * MGDL_PER_MMOL, 9); + expect(dto.sourceValue).toBe(5.6); + expect(dto.sourceUnit).toBe("mmol/L"); + // The report's own window moved with the value. + expect(dto.sourceReferenceLow).toBeCloseTo(3.9 * MGDL_PER_MMOL, 9); + expect(dto.sourceReferenceHigh).toBeCloseTo(6.0 * MGDL_PER_MMOL, 9); + // And the verdict is judged against it: 5.6 of 3.9-6.0 is in range. + expect(dto.rangeStatus).toBe("in-range"); + + const row = await prisma.labResult.findUniqueOrThrow({ + where: { id: dto.id }, + }); + expect(row.value).toBeCloseTo(5.6 * MGDL_PER_MMOL, 9); + expect(row.sourceValue).toBe(5.6); + expect(row.sourceUnit).toBe("mmol/L"); + }); + + it("flags a converted reading outside the printed window as such", async () => { + const { json } = await call("labs", "POST", { + biomarkerId: "bm-glucose", + value: 7.2, + unit: "mmol/L", + sourceReferenceLow: 3.9, + sourceReferenceHigh: 6.0, + takenAt: "2026-05-02T08:00:00.000Z", + }); + expect(json.data!.rangeStatus).toBe("above"); + }); + + it("stores a reading in the marker's unit as stated, with no conversion record", async () => { + const { json } = await call("labs", "POST", { + biomarkerId: "bm-glucose", + value: 95, + unit: "mg/dL", + takenAt: "2026-05-03T08:00:00.000Z", + }); + const dto = json.data!; + expect(dto.converted).toBe(false); + expect(dto.value).toBe(95); + expect(dto.sourceValue).toBeNull(); + expect(dto.sourceUnit).toBeNull(); + }); + + it("links a marker that had no key yet, the first time it is needed", async () => { + const before = await prisma.biomarker.findUniqueOrThrow({ + where: { id: "bm-glucose" }, + }); + // The marker was created without a key in this test; the first conversion + // above has already filled it. + expect(before.analyteKey).toBe("fasting-glucose"); + }); + + it("still refuses a pair that cannot be converted, now with the reason", async () => { + const { status, json } = await call("labs", "POST", { + biomarkerId: "bm-free", + value: 5.6, + unit: "mmol/L", + takenAt: "2026-05-04T08:00:00.000Z", + }); + expect(status).toBe(422); + expect(json.meta).toMatchObject({ + errorCode: "labs.unit.mismatch", + markerUnit: "mg/dL", + readingUnit: "mmol/L", + reason: "unknown_analyte", + }); + expect( + await prisma.labResult.count({ where: { biomarkerId: "bm-free" } }), + ).toBe(0); + }); +}); + +describe("the OCR commit", () => { + const row = { + analyte: "Glucose", + value: 5.0, + unit: "mmol/L", + referenceText: "3.9 - 5.6", + takenAt: "2026-06-01T08:00:00.000Z", + }; + + it("converts the row and reports it", async () => { + const { status, json } = await call<{ + inserted: Dto[]; + skipped: unknown[]; + }>("ocr", "POST", { rows: [row] }); + expect(status).toBe(200); + expect(json.data!.skipped).toEqual([]); + const [inserted] = json.data!.inserted; + expect(inserted.converted).toBe(true); + expect(inserted.unit).toBe("mg/dL"); + expect(inserted.value).toBeCloseTo(5.0 * MGDL_PER_MMOL, 9); + expect(inserted.sourceValue).toBe(5.0); + expect(inserted.sourceUnit).toBe("mmol/L"); + expect(inserted.sourceReferenceLow).toBeCloseTo(3.9 * MGDL_PER_MMOL, 9); + expect(inserted.sourceReferenceHigh).toBeCloseTo(5.6 * MGDL_PER_MMOL, 9); + }); + + it("treats the same scan committed again as a duplicate", async () => { + const { json } = await call<{ + inserted: unknown[]; + skipped: Array<{ reason: string }>; + }>("ocr", "POST", { rows: [row] }); + expect(json.data!.inserted).toEqual([]); + expect(json.data!.skipped.map((s) => s.reason)).toEqual(["duplicate"]); + }); + + it("skips a row whose unit cannot be converted, as before", async () => { + const { json } = await call<{ + inserted: unknown[]; + skipped: Array<{ reason: string }>; + }>("ocr", "POST", { + rows: [ + { + analyte: "My own marker", + value: 3, + unit: "mmol/L", + takenAt: "2026-06-02T08:00:00.000Z", + }, + ], + }); + expect(json.data!.inserted).toEqual([]); + expect(json.data!.skipped.map((s) => s.reason)).toEqual(["unit_mismatch"]); + }); +}); + +describe("editing and reading back", () => { + it("clears the conversion record when the value is edited", async () => { + const created = await call("labs", "POST", { + biomarkerId: "bm-glucose", + value: 6.0, + unit: "mmol/L", + takenAt: "2026-07-01T08:00:00.000Z", + }); + const id = created.json.data!.id; + expect(created.json.data!.converted).toBe(true); + + const edited = await call("one", "PUT", { value: 110 }, id); + expect(edited.status).toBe(200); + expect(edited.json.data!.value).toBe(110); + expect(edited.json.data!.converted).toBe(false); + expect(edited.json.data!.sourceValue).toBeNull(); + expect(edited.json.data!.sourceUnit).toBeNull(); + }); + + it("keeps the conversion record when only the note is edited", async () => { + const created = await call("labs", "POST", { + biomarkerId: "bm-glucose", + value: 6.1, + unit: "mmol/L", + takenAt: "2026-07-02T08:00:00.000Z", + }); + const id = created.json.data!.id; + const edited = await call("one", "PUT", { note: "fasting" }, id); + expect(edited.status).toBe(200); + expect(edited.json.data!.converted).toBe(true); + expect(edited.json.data!.sourceValue).toBe(6.1); + }); + + it("returns the fields on the list and on the single reading", async () => { + const list = await call<{ results: Dto[] }>("labs", "GET"); + const converted = list.json.data!.results.filter((r) => r.converted); + expect(converted.length).toBeGreaterThan(0); + for (const r of converted) { + expect(r.sourceValue).not.toBeNull(); + expect(r.sourceUnit).not.toBeNull(); + } + const one = await call("one", "GET", undefined, converted[0].id); + expect(one.json.data!.converted).toBe(true); + }); +}); From 747e0964ad96a5ac2a16a1111abb212b0d0818c1 Mon Sep 17 00:00:00 2001 From: BernhardBuckel <33808384+BernhardBuckel@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:41:08 +0200 Subject: [PATCH 05/12] feat(labs): show a reading's conversion in the scan review and beside the value Slice 3 of phase 3 of #1095. The scan review no longer treats every reading in another unit as unwritable. The server tells it which analyte the matched marker is (markerAnalyteKey on each extracted row); a row whose unit differs but can be converted starts selected and carries a badge saying what Save will store and what the report stated ("Saved as 100.9 mg/dL, converted from 5.6 mmol/L"). A row that cannot be converted keeps the existing blocked badge and error. Save still sends what the report printed; the server converts, as in slice 2. The review's duplicate flag also recognises a re-scan of a reading that was stored converted, by what the report printed, the same rule the commit applies. A converted reading carries a quiet "converted from 5.6 mmol/L" on the lab list (row and tile) and the history list (phone and table), so a number nobody typed can always be traced back to what the report printed. Strings in all seven locales. Refs #1095 --- docs/api/openapi.yaml | 13 ++- messages/de.json | 2 + messages/en.json | 2 + messages/es.json | 2 + messages/fr.json | 2 + messages/it.json | 2 + messages/ko.json | 2 + messages/pl.json | 2 + .../__tests__/lab-converted-note.test.tsx | 49 +++++++++++ .../labs/__tests__/lab-list-columns.test.tsx | 20 +++++ .../__tests__/ocr-review-validation.test.ts | 86 +++++++++++++++++++ .../labs/__tests__/ocr-row-editor.test.tsx | 40 +++++++++ src/components/labs/lab-converted-note.tsx | 44 ++++++++++ src/components/labs/lab-history-list.tsx | 9 ++ src/components/labs/lab-list.tsx | 3 + src/components/labs/ocr-review-types.ts | 19 ++-- src/components/labs/ocr-review-validation.ts | 59 ++++++++++++- src/components/labs/ocr-row-editor.tsx | 37 ++++++-- src/lib/labs/ocr-extract.ts | 37 +++++++- src/lib/openapi/routes/ocr.ts | 6 +- src/lib/validations/labs-ocr.ts | 6 ++ .../integration/labs-unit-conversion.test.ts | 42 +++++++++ 22 files changed, 465 insertions(+), 19 deletions(-) create mode 100644 src/components/labs/__tests__/lab-converted-note.test.tsx create mode 100644 src/components/labs/lab-converted-note.tsx diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index d6dae8879..90ae0fee6 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -68232,8 +68232,16 @@ components: - type: string - type: "null" description: "The unit the matched marker is tracked in, or null when the marker does not exist yet (it will adopt this - row's unit). A row whose `unit` differs from it cannot be committed: the commit skips it with `reason: - unit_mismatch`, and the review screen marks it before Save." + row's unit). A row whose `unit` differs from it is converted on commit when the marker's analyte is known + and the pair is in the unit-conversion registry; otherwise the commit skips it with `reason: unit_mismatch`, + and the review screen marks it before Save." + markerAnalyteKey: + anyOf: + - type: string + - type: "null" + description: The analyte the matched marker is (the unit-conversion registry's key, e.g. `fasting-glucose`), or null + when the marker does not exist yet or its name matches none. With `markerUnit` it tells a client whether a + row in another unit can be converted on commit. duplicateOf: anyOf: - type: string @@ -68250,6 +68258,7 @@ components: - confidence - biomarkerMatch - markerUnit + - markerAnalyteKey - duplicateOf additionalProperties: false description: "One proposed reading transcribed from the upload. UNTRUSTED model output annotated server-side: diff --git a/messages/de.json b/messages/de.json index 118df8494..2dede887c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -9522,6 +9522,7 @@ }, "noteLoadFailed": "Notiz konnte nicht geladen werden; sie bleibt beim Speichern unverändert.", "showingLatestOf": "Die neuesten {shown} von {total} Messwerten werden angezeigt", + "convertedFrom": "umgerechnet aus {value} {unit}", "ocr": { "scanButton": "Befund scannen", "addScan": "Dokument scannen", @@ -9568,6 +9569,7 @@ "errorUnit": "Bitte eine Einheit angeben.", "errorUnitMismatch": "Dieser Marker wird in {markerUnit} geführt. Ändere die Einheit, falls der Befund falsch gelesen wurde, oder wähle den Wert ab.", "unitDiffersBadge": "Geführt in {markerUnit}; dieser Befund nennt {unit}", + "unitConvertedBadge": "Wird als {value} {markerUnit} gespeichert, umgerechnet aus {statedValue} {unit}", "savedPartialUnitToast": "{count} Laborwerte gespeichert. Nicht gespeichert, weil die Einheit von der des Markers abweicht: {analytes}", "savedNothingUnitToast": "Nichts gespeichert: Die Einheit weicht bei {analytes} von der des Markers ab", "saveFailed": "Werte konnten nicht gespeichert werden. Bitte erneut versuchen.", diff --git a/messages/en.json b/messages/en.json index 7191e99c1..b1d26fda7 100644 --- a/messages/en.json +++ b/messages/en.json @@ -9522,6 +9522,7 @@ }, "noteLoadFailed": "Note could not be loaded; it will be kept unchanged when you save.", "showingLatestOf": "Showing the latest {shown} of {total} readings", + "convertedFrom": "converted from {value} {unit}", "ocr": { "scanButton": "Scan a report", "addScan": "Scan a document", @@ -9568,6 +9569,7 @@ "errorUnit": "Add a unit.", "errorUnitMismatch": "This marker is tracked in {markerUnit}. Change the unit if the report was misread, or deselect this reading.", "unitDiffersBadge": "Tracked in {markerUnit}; this report says {unit}", + "unitConvertedBadge": "Saved as {value} {markerUnit}, converted from {statedValue} {unit}", "savedPartialUnitToast": "{count} lab results saved. Not saved, because the unit differs from the marker's: {analytes}", "savedNothingUnitToast": "Nothing saved: the unit differs from the marker's for {analytes}", "saveFailed": "Couldn't save the readings. Please try again.", diff --git a/messages/es.json b/messages/es.json index 7ce217112..35975a9f1 100644 --- a/messages/es.json +++ b/messages/es.json @@ -9522,6 +9522,7 @@ }, "noteLoadFailed": "No se pudo cargar la nota; se mantendrá sin cambios al guardar.", "showingLatestOf": "Mostrando los {shown} resultados más recientes de {total}", + "convertedFrom": "convertido desde {value} {unit}", "ocr": { "scanButton": "Escanear un informe", "addScan": "Escanear un documento", @@ -9568,6 +9569,7 @@ "errorUnit": "Añade una unidad.", "errorUnitMismatch": "Este marcador se registra en {markerUnit}. Cambia la unidad si el informe se leyó mal o deselecciona este valor.", "unitDiffersBadge": "Registrado en {markerUnit}; este informe indica {unit}", + "unitConvertedBadge": "Se guardará como {value} {markerUnit}, convertido desde {statedValue} {unit}", "savedPartialUnitToast": "{count} resultados guardados. No guardados porque la unidad difiere de la del marcador: {analytes}", "savedNothingUnitToast": "No se guardó nada: la unidad difiere de la del marcador en {analytes}", "saveFailed": "No se pudieron guardar los valores. Inténtalo de nuevo.", diff --git a/messages/fr.json b/messages/fr.json index 5181f03ba..fecb4638e 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -9522,6 +9522,7 @@ }, "noteLoadFailed": "La note n’a pas pu être chargée ; elle restera inchangée à l’enregistrement.", "showingLatestOf": "Affichage des {shown} derniers résultats sur {total}", + "convertedFrom": "converti depuis {value} {unit}", "ocr": { "scanButton": "Scanner un compte rendu", "addScan": "Scanner un document", @@ -9568,6 +9569,7 @@ "errorUnit": "Indiquez une unité.", "errorUnitMismatch": "Ce marqueur est suivi en {markerUnit}. Modifiez l’unité si le compte rendu a été mal lu, ou désélectionnez cette valeur.", "unitDiffersBadge": "Suivi en {markerUnit} ; ce compte rendu indique {unit}", + "unitConvertedBadge": "Enregistré comme {value} {markerUnit}, converti depuis {statedValue} {unit}", "savedPartialUnitToast": "{count} résultats enregistrés. Non enregistrés, car l’unité diffère de celle du marqueur : {analytes}", "savedNothingUnitToast": "Rien n’a été enregistré : l’unité diffère de celle du marqueur pour {analytes}", "saveFailed": "Impossible d'enregistrer les valeurs. Veuillez réessayer.", diff --git a/messages/it.json b/messages/it.json index 102b09984..122e85b73 100644 --- a/messages/it.json +++ b/messages/it.json @@ -9522,6 +9522,7 @@ }, "noteLoadFailed": "Impossibile caricare la nota; resterà invariata al salvataggio.", "showingLatestOf": "Visualizzazione degli ultimi {shown} risultati su {total}", + "convertedFrom": "convertito da {value} {unit}", "ocr": { "scanButton": "Scansiona un referto", "addScan": "Scansiona un documento", @@ -9568,6 +9569,7 @@ "errorUnit": "Inserisci un'unità.", "errorUnitMismatch": "Questo marcatore è registrato in {markerUnit}. Cambia l’unità se il referto è stato letto male, oppure deseleziona questo valore.", "unitDiffersBadge": "Registrato in {markerUnit}; questo referto indica {unit}", + "unitConvertedBadge": "Verrà salvato come {value} {markerUnit}, convertito da {statedValue} {unit}", "savedPartialUnitToast": "{count} risultati salvati. Non salvati perché l’unità è diversa da quella del marcatore: {analytes}", "savedNothingUnitToast": "Nulla salvato: l’unità è diversa da quella del marcatore per {analytes}", "saveFailed": "Impossibile salvare i valori. Riprova.", diff --git a/messages/ko.json b/messages/ko.json index d05ccb066..61cd6cee5 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -9522,6 +9522,7 @@ }, "noteLoadFailed": "메모를 불러오지 못했어요. 저장해도 메모는 그대로 유지돼요.", "showingLatestOf": "측정값 {total}개 중 최근 {shown}개를 보여주고 있어요", + "convertedFrom": "{value} {unit}에서 변환됨", "ocr": { "scanButton": "결과지 스캔", "addScan": "문서 스캔", @@ -9568,6 +9569,7 @@ "errorUnit": "단위를 입력하세요.", "errorUnitMismatch": "이 지표는 {markerUnit} 단위로 기록됩니다. 결과지를 잘못 읽었다면 단위를 바꾸거나 이 값의 선택을 해제하세요.", "unitDiffersBadge": "{markerUnit} 단위로 기록 중; 이 결과지는 {unit}", + "unitConvertedBadge": "{value} {markerUnit}(으)로 저장됩니다. {statedValue} {unit}에서 변환됨", "savedPartialUnitToast": "검사 결과 {count}개를 저장했습니다. 단위가 지표와 달라 저장하지 않은 항목: {analytes}", "savedNothingUnitToast": "저장된 항목이 없습니다: {analytes}의 단위가 지표와 다릅니다", "saveFailed": "측정값을 저장하지 못했어요. 다시 시도해 주세요.", diff --git a/messages/pl.json b/messages/pl.json index a8d64dd6c..51fd76613 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -9522,6 +9522,7 @@ }, "noteLoadFailed": "Nie udało się wczytać notatki; pozostanie niezmieniona po zapisaniu.", "showingLatestOf": "Wyświetlanie najnowszych {shown} z {total} wyników", + "convertedFrom": "przeliczono z {value} {unit}", "ocr": { "scanButton": "Zeskanuj wynik", "addScan": "Zeskanuj dokument", @@ -9568,6 +9569,7 @@ "errorUnit": "Podaj jednostkę.", "errorUnitMismatch": "Ten wskaźnik jest prowadzony w {markerUnit}. Zmień jednostkę, jeśli wynik został źle odczytany, albo odznacz tę wartość.", "unitDiffersBadge": "Prowadzony w {markerUnit}; ten wynik podaje {unit}", + "unitConvertedBadge": "Zostanie zapisane jako {value} {markerUnit}, przeliczone z {statedValue} {unit}", "savedPartialUnitToast": "Zapisano {count} wyników. Nie zapisano, bo jednostka różni się od jednostki wskaźnika: {analytes}", "savedNothingUnitToast": "Nic nie zapisano: jednostka różni się od jednostki wskaźnika dla {analytes}", "saveFailed": "Nie udało się zapisać wyników. Spróbuj ponownie.", diff --git a/src/components/labs/__tests__/lab-converted-note.test.tsx b/src/components/labs/__tests__/lab-converted-note.test.tsx new file mode 100644 index 000000000..2f0c79c49 --- /dev/null +++ b/src/components/labs/__tests__/lab-converted-note.test.tsx @@ -0,0 +1,49 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; + +import { I18nProvider } from "@/lib/i18n/context"; + +import { LabConvertedNote } from "../lab-converted-note"; + +function render( + reading: Parameters[0]["reading"], + locale: "en" | "de" = "en", +) { + return renderToStaticMarkup( + + + , + ); +} + +describe("", () => { + it("says what the report printed beside a converted reading", () => { + const html = render({ + converted: true, + sourceValue: 5.6, + sourceUnit: "mmol/L", + }); + expect(html).toContain('data-slot="lab-converted-note"'); + expect(html).toContain("converted from 5.6 mmol/L"); + }); + + it("formats the number for the reader's language", () => { + expect( + render({ converted: true, sourceValue: 5.6, sourceUnit: "mmol/L" }, "de"), + ).toContain("umgerechnet aus 5,6 mmol/L"); + }); + + it("trims a long fraction the way the reading itself is trimmed", () => { + expect( + render({ converted: true, sourceValue: 5.6123456, sourceUnit: "mmol/L" }), + ).toContain("converted from 5.61 mmol/L"); + }); + + it.each([ + [{ converted: false, sourceValue: null, sourceUnit: null }], + [{ converted: true, sourceValue: null, sourceUnit: "mmol/L" }], + [{ converted: true, sourceValue: 5.6, sourceUnit: null }], + ])("renders nothing for %j", (reading) => { + expect(render(reading)).toBe(""); + }); +}); diff --git a/src/components/labs/__tests__/lab-list-columns.test.tsx b/src/components/labs/__tests__/lab-list-columns.test.tsx index 21e133d8a..44c7505b6 100644 --- a/src/components/labs/__tests__/lab-list-columns.test.tsx +++ b/src/components/labs/__tests__/lab-list-columns.test.tsx @@ -29,6 +29,9 @@ function reading(over: Partial): LabResultDto { value: 80, valueText: null, unit: "ng/mL", + converted: false, + sourceValue: null, + sourceUnit: null, referenceLow: 30, referenceHigh: 400, catalogReferenceLow: 30, @@ -165,4 +168,21 @@ describe(" columns", () => { } }); }); + + it("notes where a converted reading came from, and only for that reading", () => { + const html = renderList([ + reading({ + id: "c", + biomarkerId: "b1", + analyte: "Glucose", + unit: "mg/dL", + converted: true, + sourceValue: 5.6, + sourceUnit: "mmol/L", + }), + reading({ id: "d", biomarkerId: "b2", analyte: "Ferritin" }), + ]); + expect(count(html, 'data-slot="lab-converted-note"')).toBe(1); + expect(html).toContain("converted from 5.6 mmol/L"); + }); }); diff --git a/src/components/labs/__tests__/ocr-review-validation.test.ts b/src/components/labs/__tests__/ocr-review-validation.test.ts index ac47ea7a9..234d18a59 100644 --- a/src/components/labs/__tests__/ocr-review-validation.test.ts +++ b/src/components/labs/__tests__/ocr-review-validation.test.ts @@ -5,6 +5,8 @@ import { seedReviewRows } from "../ocr-review-types"; import { collectRowErrors, planSave, + readingConversion, + readingUnitBlocked, readingUnitDiffers, toCommitRow, validateReviewRow, @@ -24,6 +26,7 @@ function row(overrides: Partial = {}): OcrReviewRow { confidence: { analyte: 1, value: 1, unit: 1, range: 1 }, biomarkerMatch: "existing", markerUnit: "mmol/L", + markerAnalyteKey: null, duplicateOf: null, confirmed: true, ...overrides, @@ -200,6 +203,7 @@ describe("a reading in another unit than its marker's", () => { confidence: { analyte: 1, value: 1, unit: 1, range: 1 }, biomarkerMatch: "existing" as const, markerUnit, + markerAnalyteKey: null, duplicateOf: null, }); const [same, other, fresh] = seedReviewRows( @@ -212,3 +216,85 @@ describe("a reading in another unit than its marker's", () => { expect(fresh.confirmed).toBe(true); }); }); + +describe("a reading in another unit that can be converted", () => { + // Glucose stated in mmol/L against a marker kept in mg/dL, and the analyte is + // known: Save converts it, so nothing blocks and the row starts selected. + const glucose = (overrides: Partial = {}) => + row({ + analyte: "Glucose", + value: 5.6, + unit: "mmol/L", + markerUnit: "mg/dL", + markerAnalyteKey: "fasting-glucose", + ...overrides, + }); + + it("previews what Save will store, beside what the report stated", () => { + const conversion = readingConversion(glucose()); + expect(conversion).toMatchObject({ + unit: "mg/dL", + statedValue: 5.6, + statedUnit: "mmol/L", + }); + expect(conversion!.value).toBeCloseTo(5.6 * 18.0182, 9); + }); + + it("is not blocked, and not a validation error", () => { + expect(readingUnitDiffers(glucose())).toBe(true); + expect(readingUnitBlocked(glucose())).toBe(false); + expect(validateReviewRow(glucose())).toEqual({}); + }); + + it("sends what the report printed, for the server to convert", () => { + const payload = toCommitRow(glucose()); + expect(payload).toMatchObject({ + analyte: "Glucose", + value: 5.6, + unit: "mmol/L", + }); + expect(planSave([glucose()]).kind).toBe("ready"); + }); + + it("starts selected on the review screen", () => { + const dto = { + analyte: "Glucose", + value: 5.6, + valueText: null, + unit: "mmol/L", + referenceLow: null, + referenceHigh: null, + referenceText: null, + takenAt: "2026-06-10", + confidence: { analyte: 1, value: 1, unit: 1, range: 1 }, + biomarkerMatch: "existing" as const, + markerUnit: "mg/dL", + markerAnalyteKey: "fasting-glucose", + duplicateOf: null, + }; + const [converts, blocked] = seedReviewRows( + [dto, { ...dto, markerAnalyteKey: null }], + null, + ); + expect(converts.confirmed).toBe(true); + expect(blocked.confirmed).toBe(false); + }); + + it("stays blocked when the marker has no known analyte, or the pair is not in the registry", () => { + expect(readingUnitBlocked(glucose({ markerAnalyteKey: null }))).toBe(true); + expect(readingConversion(glucose({ markerAnalyteKey: null }))).toBeNull(); + expect(validateReviewRow(glucose({ markerAnalyteKey: null }))).toEqual({ + unitMismatch: true, + }); + // The analyte is known but the unit is not one it reaches. + expect(readingUnitBlocked(glucose({ unit: "%" }))).toBe(true); + }); + + it("has nothing to preview when the units agree or the row is qualitative", () => { + expect(readingConversion(glucose({ unit: "mg/dL" }))).toBeNull(); + expect( + readingConversion(glucose({ value: null, valueText: "negative" })), + ).toBeNull(); + expect(readingConversion(glucose({ value: null }))).toBeNull(); + }); +}); diff --git a/src/components/labs/__tests__/ocr-row-editor.test.tsx b/src/components/labs/__tests__/ocr-row-editor.test.tsx index 0079dfafd..aef017711 100644 --- a/src/components/labs/__tests__/ocr-row-editor.test.tsx +++ b/src/components/labs/__tests__/ocr-row-editor.test.tsx @@ -19,6 +19,7 @@ const baseRow: OcrReviewRow = { confidence: { analyte: 1, value: 1, unit: 1, range: 1 }, biomarkerMatch: "existing", markerUnit: "mmol/L", + markerAnalyteKey: null, duplicateOf: null, confirmed: true, }; @@ -155,3 +156,42 @@ describe(" unit differs from the marker's", () => { expect(html).toContain("Geführt in mmol/L; dieser Befund nennt mg/dL"); }); }); + +describe(" unit differs but converts", () => { + const converts: OcrReviewRow = { + ...baseRow, + analyte: "Glucose", + value: 5.6, + takenAt: "2026-06-10", + unit: "mmol/L", + markerUnit: "mg/dL", + markerAnalyteKey: "fasting-glucose", + }; + + it("says what Save will store and what the report stated, not that it is blocked", () => { + const html = render(undefined, "en", converts); + expect(html).toContain('data-slot="ocr-row-converted-badge"'); + expect(html).toContain("Saved as 100.9 mg/dL, converted from 5.6 mmol/L"); + expect(html).not.toContain("Tracked in"); + expect(html).not.toContain('aria-invalid="true"'); + }); + + it("speaks German too", () => { + const html = render(undefined, "de", converts); + expect(html).toContain("umgerechnet aus 5,6 mmol/L"); + }); + + it("shows nothing when the units agree", () => { + const html = render(undefined, "en", { ...converts, unit: "mg/dL" }); + expect(html).not.toContain("ocr-row-converted-badge"); + }); + + it("still says it is blocked when the analyte is not known", () => { + const html = render(undefined, "en", { + ...converts, + markerAnalyteKey: null, + }); + expect(html).toContain("Tracked in mg/dL; this report says mmol/L"); + expect(html).not.toContain("ocr-row-converted-badge"); + }); +}); diff --git a/src/components/labs/lab-converted-note.tsx b/src/components/labs/lab-converted-note.tsx new file mode 100644 index 000000000..dfbdc9e85 --- /dev/null +++ b/src/components/labs/lab-converted-note.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { useTranslations } from "@/lib/i18n/context"; +import { formatLabValue } from "@/lib/labs/format-value"; + +import { useLabNumber } from "./use-lab-format"; + +/** + * A quiet "converted from 5.6 mmol/L" beside a reading whose stored number is a + * conversion of what the report printed, so a number nobody typed can always be + * traced back. Renders nothing for a reading stored as stated. + */ +export function LabConvertedNote({ + reading, + className, +}: { + reading: { + converted: boolean; + sourceValue: number | null; + sourceUnit: string | null; + }; + className?: string; +}) { + const { t } = useTranslations(); + const labNumber = useLabNumber(); + if ( + !reading.converted || + reading.sourceValue === null || + reading.sourceUnit === null + ) { + return null; + } + return ( + + {t("labs.convertedFrom", { + value: formatLabValue(reading.sourceValue, labNumber), + unit: reading.sourceUnit, + })} + + ); +} diff --git a/src/components/labs/lab-history-list.tsx b/src/components/labs/lab-history-list.tsx index 1e5348cd9..6e581f259 100644 --- a/src/components/labs/lab-history-list.tsx +++ b/src/components/labs/lab-history-list.tsx @@ -30,6 +30,7 @@ import { useTableSort } from "@/hooks/use-table-sort"; import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch"; import { localizedApiError } from "@/lib/api/localized-error"; import { formatLabReading } from "@/lib/labs/format-value"; +import { LabConvertedNote } from "./lab-converted-note"; import { resolveNoteForUpdate } from "@/lib/labs/note-update"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; @@ -381,6 +382,10 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
{formatLabReading(r, labNumber)} +
{formatLabReading(r, labNumber)} + {labDate(group.latest.takenAt)} +
); @@ -429,6 +431,7 @@ export function LabList({ {labDate(group.latest.takenAt)} + {group.readings.length > 1 ? ( {t("labs.readingsCount", { diff --git a/src/components/labs/ocr-review-types.ts b/src/components/labs/ocr-review-types.ts index 7def77782..c05b394b2 100644 --- a/src/components/labs/ocr-review-types.ts +++ b/src/components/labs/ocr-review-types.ts @@ -7,7 +7,7 @@ */ import type { OcrExtractedRowDto } from "@/lib/validations/labs-ocr"; -import { readingUnitDiffers } from "./ocr-review-validation"; +import { readingUnitBlocked } from "./ocr-review-validation"; export interface OcrReviewRow { /** Stable client key for the list (the analyte+index at seed time). */ @@ -26,9 +26,12 @@ export interface OcrReviewRow { biomarkerMatch: "new" | "existing"; /** * The unit the matched marker is tracked in; null for a marker that does not - * exist yet. A numeric row whose `unit` differs from it cannot be saved. + * exist yet. A numeric row whose `unit` differs from it is converted on + * Save when it can be (see `readingConversion`) and cannot be saved when not. */ markerUnit: string | null; + /** Which analyte the matched marker is (conversion registry key), or null. */ + markerAnalyteKey: string | null; /** Non-null when the server flagged a likely duplicate of a live reading. */ duplicateOf: string | null; /** Whether the row is selected for saving. Defaults false for duplicates. */ @@ -53,17 +56,21 @@ export function seedReviewRows( confidence: row.confidence, biomarkerMatch: row.biomarkerMatch, markerUnit: row.markerUnit, + markerAnalyteKey: row.markerAnalyteKey, duplicateOf: row.duplicateOf, // A flagged duplicate starts unchecked, and so does a reading in another - // unit than its marker's (it carries a badge saying why, and ticking it - // marks the unit field until it is fixed); everything else starts - // confirmed. + // unit than its marker's that cannot be converted (it carries a badge + // saying why, and ticking it marks the unit field until it is fixed); a + // reading that converts starts confirmed, with a badge saying what Save + // will store; everything else starts confirmed. confirmed: row.duplicateOf === null && - !readingUnitDiffers({ + !readingUnitBlocked({ + value: row.value, valueText: row.valueText, unit: row.unit, markerUnit: row.markerUnit, + markerAnalyteKey: row.markerAnalyteKey, }), })); } diff --git a/src/components/labs/ocr-review-validation.ts b/src/components/labs/ocr-review-validation.ts index a2ab7fea2..339fbe992 100644 --- a/src/components/labs/ocr-review-validation.ts +++ b/src/components/labs/ocr-review-validation.ts @@ -7,6 +7,7 @@ * build the payload: it returns null exactly when `validateReviewRow` reports * at least one field. */ +import { convertLabValue } from "@/lib/labs/unit-conversion/convert"; import { sameLabUnit } from "@/lib/labs/unit-normalise"; import type { OcrReviewRow } from "./ocr-review-types"; @@ -46,6 +47,60 @@ export function readingUnitDiffers(row: { return !sameLabUnit(stated, row.markerUnit); } +/** + * What Save will store for a numeric row whose unit differs from its marker's + * and can be converted: the value in the marker's unit, beside what the report + * stated. Null when the units agree, the row is qualitative, or the pair has no + * conversion. The commit does the same conversion on the server; this only lets + * the screen say so before Save. + */ +export function readingConversion(row: { + value: number | null | undefined; + valueText: string | null | undefined; + unit: string | null | undefined; + markerUnit: string | null | undefined; + markerAnalyteKey: string | null | undefined; +}): { + value: number; + unit: string; + statedValue: number; + statedUnit: string; +} | null { + if (!readingUnitDiffers(row)) return null; + if (row.value === null || row.value === undefined) return null; + if (!Number.isFinite(row.value)) return null; + const statedUnit = (row.unit ?? "").trim(); + const converted = convertLabValue({ + analyteKey: row.markerAnalyteKey, + value: row.value, + from: statedUnit, + to: row.markerUnit as string, + }); + if (!converted.ok) return null; + return { + value: converted.value, + unit: row.markerUnit as string, + statedValue: row.value, + statedUnit, + }; +} + +/** + * A numeric reading in another unit than its marker's that cannot be + * converted: the one case that still cannot be saved. A row with no number yet + * has nothing to convert, so it is blocked here as well as on `value`. + */ +export function readingUnitBlocked(row: { + value: number | null | undefined; + valueText: string | null | undefined; + unit: string | null | undefined; + markerUnit: string | null | undefined; + markerAnalyteKey: string | null | undefined; +}): boolean { + if (!readingUnitDiffers(row)) return false; + return readingConversion(row) === null; +} + export function validateReviewRow(row: OcrReviewRow): OcrRowErrors { const errors: OcrRowErrors = {}; if (!row.analyte.trim()) errors.analyte = true; @@ -56,7 +111,7 @@ export function validateReviewRow(row: OcrReviewRow): OcrRowErrors { } else { if (row.value === null || !Number.isFinite(row.value)) errors.value = true; if (!(row.unit ?? "").trim()) errors.unit = true; - if (readingUnitDiffers(row)) errors.unitMismatch = true; + if (readingUnitBlocked(row)) errors.unitMismatch = true; } return errors; } @@ -80,7 +135,7 @@ export function toCommitRow(row: OcrReviewRow): OcrCommitRowInput | null { if (row.value === null || !Number.isFinite(row.value)) return null; const unit = (row.unit ?? "").trim(); if (!unit) return null; - if (readingUnitDiffers(row)) return null; + if (readingUnitBlocked(row)) return null; return { analyte, value: row.value, diff --git a/src/components/labs/ocr-row-editor.tsx b/src/components/labs/ocr-row-editor.tsx index b103f09f7..338c90a85 100644 --- a/src/components/labs/ocr-row-editor.tsx +++ b/src/components/labs/ocr-row-editor.tsx @@ -32,7 +32,12 @@ import { Label } from "@/components/ui/label"; import { useTranslations } from "@/lib/i18n/context"; import type { OcrReviewRow } from "./ocr-review-types"; -import { readingUnitDiffers, type OcrRowErrors } from "./ocr-review-validation"; +import { + readingConversion, + readingUnitBlocked, + type OcrRowErrors, +} from "./ocr-review-validation"; +import { useLabNumber } from "./use-lab-format"; /** Below this per-field confidence the field is flagged for a second look. */ const CONFIDENCE_THRESHOLD = 0.6; @@ -76,10 +81,13 @@ export function OcrRowEditor({ message={t(messageKey, params)} /> ) : null; - // Stated before Save, not only after a failed attempt: a reading in another - // unit than its marker's cannot be written, and the person should see why - // the row is unticked (or will not save) while they are still reading it. - const unitDiffers = readingUnitDiffers(row); + // Stated before Save, not only after a failed attempt. A reading in another + // unit than its marker's is either converted (the badge says what Save will + // store) or cannot be written (the person should see why the row is unticked, + // or will not save, while they are still reading it). + const unitBlocked = readingUnitBlocked(row); + const conversion = readingConversion(row); + const labNumber = useLabNumber(); const isQualitative = row.valueText !== null && row.valueText !== undefined; const lowValueConfidence = @@ -169,7 +177,7 @@ export function OcrRowEditor({ {t("labs.ocr.duplicateWarning")} ) : null} - {unitDiffers ? ( + {unitBlocked ? ( @@ -180,6 +188,23 @@ export function OcrRowEditor({ ) : null} + {conversion ? ( + + + + {t("labs.ocr.unitConvertedBadge", { + value: labNumber(conversion.value), + markerUnit: conversion.unit, + statedValue: labNumber(conversion.statedValue), + unit: conversion.statedUnit, + })} + + + ) : null} {lowRowConfidence ? ( diff --git a/src/lib/labs/ocr-extract.ts b/src/lib/labs/ocr-extract.ts index f217f2657..657445fd3 100644 --- a/src/lib/labs/ocr-extract.ts +++ b/src/lib/labs/ocr-extract.ts @@ -28,6 +28,8 @@ import { isUnreadableRange, parseReferenceRange, } from "@/lib/labs/parse-reference-range"; +import { matchAnalyteKey } from "@/lib/labs/analyte-match"; +import { sameLabUnit } from "@/lib/labs/unit-normalise"; import { annotate } from "@/lib/logging/context"; import { extractedLabsSchema, @@ -144,8 +146,17 @@ export async function annotateRow( // Biomarker match — case-insensitive on the catalog identity, presence-only. const existingBiomarker = await prisma.biomarker.findFirst({ where: { userId, name: { equals: analyte, mode: "insensitive" } }, - select: { id: true, unit: true }, + select: { id: true, name: true, unit: true, analyteKey: true }, }); + // The analyte the marker is, so the review can say whether a row in another + // unit converts. Read only: the key is filled when a reading is written. + const markerAnalyteKey = existingBiomarker + ? (existingBiomarker.analyteKey ?? + matchAnalyteKey({ + name: existingBiomarker.name, + unit: existingBiomarker.unit, + })) + : null; // Duplicate check: a live lab_results row with the same analyte (fuzzy, // case-insensitive), the same calendar day, and the same value/valueText is @@ -161,7 +172,14 @@ export async function annotateRow( analyte: { equals: analyte, mode: "insensitive" }, takenAt: labReadingDaySearchRange(takenAt), }, - select: { id: true, value: true, valueText: true, takenAt: true }, + select: { + id: true, + value: true, + valueText: true, + sourceValue: true, + sourceUnit: true, + takenAt: true, + }, take: 100, }); const candidates = rows.filter( @@ -172,6 +190,20 @@ export async function annotateRow( duplicateOf = c.id; break; } + // A reading stored converted holds the number in its marker's unit; what + // the report printed is beside it. The same number in the same unit is + // the same reading (the commit applies the same rule). + if ( + row.value !== null && + row.unit !== null && + c.sourceValue !== null && + c.sourceValue === row.value && + c.sourceUnit !== null && + sameLabUnit(c.sourceUnit, row.unit) + ) { + duplicateOf = c.id; + break; + } if ( row.valueText !== null && c.valueText !== null && @@ -199,6 +231,7 @@ export async function annotateRow( confidence: row.confidence, biomarkerMatch: existingBiomarker ? "existing" : "new", markerUnit: existingBiomarker?.unit ?? null, + markerAnalyteKey, duplicateOf, }; } diff --git a/src/lib/openapi/routes/ocr.ts b/src/lib/openapi/routes/ocr.ts index 94ffe0c80..af682c554 100644 --- a/src/lib/openapi/routes/ocr.ts +++ b/src/lib/openapi/routes/ocr.ts @@ -75,7 +75,11 @@ const extractedRow = z biomarkerMatch: z.enum(["new", "existing"]), markerUnit: z.string().nullable().meta({ description: - "The unit the matched marker is tracked in, or null when the marker does not exist yet (it will adopt this row's unit). A row whose `unit` differs from it cannot be committed: the commit skips it with `reason: unit_mismatch`, and the review screen marks it before Save.", + "The unit the matched marker is tracked in, or null when the marker does not exist yet (it will adopt this row's unit). A row whose `unit` differs from it is converted on commit when the marker's analyte is known and the pair is in the unit-conversion registry; otherwise the commit skips it with `reason: unit_mismatch`, and the review screen marks it before Save.", + }), + markerAnalyteKey: z.string().nullable().meta({ + description: + "The analyte the matched marker is (the unit-conversion registry's key, e.g. `fasting-glucose`), or null when the marker does not exist yet or its name matches none. With `markerUnit` it tells a client whether a row in another unit can be converted on commit.", }), duplicateOf: z.string().nullable(), }) diff --git a/src/lib/validations/labs-ocr.ts b/src/lib/validations/labs-ocr.ts index 59c076f61..d19bb0f4e 100644 --- a/src/lib/validations/labs-ocr.ts +++ b/src/lib/validations/labs-ocr.ts @@ -131,6 +131,12 @@ export interface OcrExtractedRowDto { * so before Save when the scanned unit differs. */ markerUnit: string | null; + /** + * Which analyte the matched marker is (unit-conversion registry key), or null + * when the marker is new or none is known. With `markerUnit` it tells the + * review screen whether a row in another unit can be converted on Save. + */ + markerAnalyteKey: string | null; duplicateOf: string | null; } diff --git a/tests/integration/labs-unit-conversion.test.ts b/tests/integration/labs-unit-conversion.test.ts index b6199c01d..9634476ac 100644 --- a/tests/integration/labs-unit-conversion.test.ts +++ b/tests/integration/labs-unit-conversion.test.ts @@ -250,6 +250,48 @@ describe("the OCR commit", () => { expect(json.data!.skipped.map((s) => s.reason)).toEqual(["duplicate"]); }); + it("tells the scan review which analyte the marker is, and flags a re-scan as a duplicate", async () => { + const { annotateRow } = await import("@/lib/labs/ocr-extract"); + const scanned = { + analyte: "Glucose", + value: 5.0, + valueText: null, + unit: "mmol/L", + referenceLow: null, + referenceHigh: null, + referenceText: null, + takenAt: "2026-06-01", + confidence: { analyte: 1, value: 1, unit: 1, range: 1 }, + }; + const annotated = await annotateRow(USER, scanned, "2026-06-01", "UTC"); + expect(annotated).toMatchObject({ + biomarkerMatch: "existing", + markerUnit: "mg/dL", + markerAnalyteKey: "fasting-glucose", + }); + // The converted reading from the commit above holds 90.09 mg/dL; the scan + // says 5.0 mmol/L, which is what the report printed for it. + expect(annotated.duplicateOf).not.toBeNull(); + + // A different value is not a duplicate. + const other = await annotateRow( + USER, + { ...scanned, value: 6.5 }, + "2026-06-01", + "UTC", + ); + expect(other.duplicateOf).toBeNull(); + + // A marker the registry knows nothing about reports no analyte. + const free = await annotateRow( + USER, + { ...scanned, analyte: "My own marker", unit: "mg/dL" }, + "2026-06-01", + "UTC", + ); + expect(free.markerAnalyteKey).toBeNull(); + }); + it("skips a row whose unit cannot be converted, as before", async () => { const { json } = await call<{ inserted: unknown[]; From cc0313c955293d9f0cb9416c14e835965da49670 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 14 Oct 2026 18:59:32 +0200 Subject: [PATCH 06/12] fix(labs): re-derive a marker's analyte key when its name or unit changes The key is a function of the marker's name and unit, but it was only ever filled, never replaced, so a marker renamed from Glucose to Cholesterol kept converting readings with the glucose factor. The marker edit now re-derives it with the same match the creation paths use (clearing it when the new name matches nothing), and the fill step a reading runs brings a stored key back in line with the name, so a key written before this fix is corrected the first time it matters. The scan review derives the key the same way as the commit. --- src/app/api/biomarkers/[id]/route.ts | 11 ++ src/lib/labs/analyte-key-store.ts | 66 +++++--- src/lib/labs/ocr-extract.ts | 12 +- tests/integration/labs-analyte-key.test.ts | 171 +++++++++++++++++++-- 4 files changed, 216 insertions(+), 44 deletions(-) diff --git a/src/app/api/biomarkers/[id]/route.ts b/src/app/api/biomarkers/[id]/route.ts index d3aec468b..506333ad3 100644 --- a/src/app/api/biomarkers/[id]/route.ts +++ b/src/app/api/biomarkers/[id]/route.ts @@ -17,6 +17,7 @@ import { decryptContextFromBytes, encryptContextToBytes, } from "@/lib/labs/biomarker-store"; +import { matchAnalyteKey } from "@/lib/labs/analyte-match"; import { annotate } from "@/lib/logging/context"; import { updateBiomarkerSchema } from "@/lib/validations/biomarkers"; import { effectiveBound, isInvertedRange } from "@/lib/validations/labs"; @@ -176,6 +177,16 @@ export const PUT = apiHandler( if (d.upperBound !== undefined) data.upperBound = d.upperBound; if (d.panel !== undefined) data.panel = d.panel; if (d.hidden !== undefined) data.hidden = d.hidden; + // The analyte a marker is follows from its name and unit, the same match + // the creation paths use. A rename must move it, or a marker renamed from + // "Glucose" to "Cholesterol" would keep converting readings with the + // glucose factor; a name that now matches nothing clears it. + if (d.name !== undefined || d.unit !== undefined) { + data.analyteKey = matchAnalyteKey({ + name: d.name ?? existing.name, + unit: d.unit ?? existing.unit, + }); + } // Inverted-range guard for a PARTIAL bound update. The schema refine only // fires when both bounds arrive together; moving a single bound past the diff --git a/src/lib/labs/analyte-key-store.ts b/src/lib/labs/analyte-key-store.ts index f11720e53..dfefa416d 100644 --- a/src/lib/labs/analyte-key-store.ts +++ b/src/lib/labs/analyte-key-store.ts @@ -1,37 +1,53 @@ /** * Link a marker to its analyte for the unit conversion. * - * The key is set when a marker is created and, for a marker that already - * existed, the first time a reading needs it. It is never overwritten: a key a - * person (or an earlier pass) set stands, and `updateMany` is gated on the - * column still being null, so two concurrent writers converge. A marker whose - * name matches no analyte keeps a null key and gets no conversion. + * There is no manual choice of analyte: the key is a function of the marker's + * name and unit (`matchAnalyteKey`). It is set when a marker is created and + * re-derived whenever its name or unit changes, and this step brings a stored + * key back in line with that function the first time a reading needs it. So a + * marker that never had a key gets one, and a key that no longer agrees with + * the name (written before a rename re-derived it, or in an older form) is + * replaced, or cleared when the name now matches nothing. A key that agrees is + * left alone, so the step is idempotent. + * + * The write is gated on the column still holding what the caller read, so two + * concurrent writers converge, and a caller holding a stale copy re-reads the + * row rather than writing over a newer value. */ import type { PrismaClient } from "@/generated/prisma/client"; import { matchAnalyteKey } from "./analyte-match"; +interface MarkerKeyState { + id: string; + name: string; + unit: string; + analyteKey?: string | null; +} + export async function ensureAnalyteKey( client: Pick, - marker: { - id: string; - name: string; - unit: string; - analyteKey?: string | null; - }, + marker: MarkerKeyState, ): Promise { - if (marker.analyteKey) return marker.analyteKey; - const key = matchAnalyteKey({ name: marker.name, unit: marker.unit }); - if (!key) return null; - const { count } = await client.biomarker.updateMany({ - where: { id: marker.id, analyteKey: null }, - data: { analyteKey: key }, - }); - if (count > 0) return key; - // Another writer set it first; read what stands. - const current = await client.biomarker.findUnique({ - where: { id: marker.id }, - select: { analyteKey: true }, - }); - return current?.analyteKey ?? null; + let current: MarkerKeyState = marker; + // Two rounds: the caller's copy, then the row as it stands if that copy was + // stale. A third writer racing both is vanishingly rare; the derived key is + // still the right answer for the name this call saw. + for (let round = 0; round < 2; round++) { + const derived = matchAnalyteKey({ name: current.name, unit: current.unit }); + const stored = current.analyteKey ?? null; + if (stored === derived) return derived; + const { count } = await client.biomarker.updateMany({ + where: { id: current.id, analyteKey: stored }, + data: { analyteKey: derived }, + }); + if (count > 0) return derived; + const row = await client.biomarker.findUnique({ + where: { id: current.id }, + select: { id: true, name: true, unit: true, analyteKey: true }, + }); + if (!row) return null; + current = row; + } + return matchAnalyteKey({ name: current.name, unit: current.unit }); } diff --git a/src/lib/labs/ocr-extract.ts b/src/lib/labs/ocr-extract.ts index 657445fd3..59740c81a 100644 --- a/src/lib/labs/ocr-extract.ts +++ b/src/lib/labs/ocr-extract.ts @@ -146,16 +146,18 @@ export async function annotateRow( // Biomarker match — case-insensitive on the catalog identity, presence-only. const existingBiomarker = await prisma.biomarker.findFirst({ where: { userId, name: { equals: analyte, mode: "insensitive" } }, - select: { id: true, name: true, unit: true, analyteKey: true }, + select: { id: true, name: true, unit: true }, }); // The analyte the marker is, so the review can say whether a row in another - // unit converts. Read only: the key is filled when a reading is written. + // unit converts. Derived from the name and unit exactly as the commit derives + // it (a stored key is only that derivation, written down), so the review and + // the commit cannot disagree. Read only: the key is stored when a reading is + // written. const markerAnalyteKey = existingBiomarker - ? (existingBiomarker.analyteKey ?? - matchAnalyteKey({ + ? matchAnalyteKey({ name: existingBiomarker.name, unit: existingBiomarker.unit, - })) + }) : null; // Duplicate check: a live lab_results row with the same analyte (fuzzy, diff --git a/tests/integration/labs-analyte-key.test.ts b/tests/integration/labs-analyte-key.test.ts index df12eb7c3..595c70261 100644 --- a/tests/integration/labs-analyte-key.test.ts +++ b/tests/integration/labs-analyte-key.test.ts @@ -1,15 +1,19 @@ /** - * A marker is linked to its analyte, once, and the link is never overwritten. + * A marker is linked to its analyte, and the link follows its name and unit. * * `Biomarker.analyteKey` says which analyte a marker is, so a reading in another - * unit can be converted (#1095). It is set when a marker is created, by all - * three creation paths, and, for a marker that already exists, the first time - * it is needed. Against a real Postgres: + * unit can be converted (#1095). There is no manual choice: the key is derived + * from the name and unit. It is set when a marker is created, by all three + * creation paths, re-derived when a rename or unit change moves it, and, for a + * marker that already exists, brought in line the first time it is needed. + * Against a real Postgres: * - a marker created by name is linked when the name is a known analyte and * its unit is one the analyte reaches, and left null otherwise; * - the key reaches the API response; - * - `ensureAnalyteKey` fills a null key once and never replaces a key that is - * set, even by a different match, and two concurrent calls converge; + * - a rename moves the key, or clears it, and the next reading in another + * unit converts with the new analyte's factor; + * - `ensureAnalyteKey` fills a null key, replaces one that disagrees with the + * name, and two concurrent calls converge; * - the mint path used by the lab writes and the legacy backfill set it too. */ import { NextRequest } from "next/server"; @@ -100,6 +104,109 @@ describe("POST /api/biomarkers", () => { }); }); +async function patch(id: string, body: Record) { + const s = await prisma.session.create({ + data: { userId: USER, expiresAt: new Date(Date.now() + 3_600_000) }, + }); + cookieJar.clear(); + cookieJar.set("healthlog_session", s.id); + const { PUT } = await import("@/app/api/biomarkers/[id]/route"); + return PUT( + new NextRequest(`http://localhost/api/biomarkers/${id}`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }), + { params: Promise.resolve({ id }) }, + ); +} + +async function postReading(body: Record) { + const s = await prisma.session.create({ + data: { userId: USER, expiresAt: new Date(Date.now() + 3_600_000) }, + }); + cookieJar.clear(); + cookieJar.set("healthlog_session", s.id); + const { POST } = await import("@/app/api/labs/route"); + return POST( + new NextRequest("http://localhost/api/labs", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }), + ); +} + +describe("PUT /api/biomarkers/[id]", () => { + it("moves the key with a rename, and the next reading converts with the new analyte", async () => { + const created = await post({ name: "Blood sugar", unit: "mg/dL" }); + const { data: marker } = (await created.json()) as { + data: { id: string; analyteKey: string | null }; + }; + expect(marker.analyteKey).toBe("fasting-glucose"); + + const res = await patch(marker.id, { name: "Cholesterol" }); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { analyteKey: string | null }; + }; + expect(body.data.analyteKey).toBe("total-cholesterol"); + expect( + (await prisma.biomarker.findUnique({ where: { id: marker.id } })) + ?.analyteKey, + ).toBe("total-cholesterol"); + + // 5.2 mmol/L of cholesterol is 201.07 mg/dL (C27H46O, 386.664 g/mol). With + // the glucose key left over it would have been stored as 93.7. + const reading = await postReading({ + biomarkerId: marker.id, + value: 5.2, + unit: "mmol/L", + takenAt: "2026-05-03T08:00:00.000Z", + }); + expect(reading.status).toBe(201); + const { data: dto } = (await reading.json()) as { + data: { value: number; unit: string; converted: boolean }; + }; + expect(dto.converted).toBe(true); + expect(dto.unit).toBe("mg/dL"); + expect(dto.value.toFixed(2)).toBe("201.07"); + }); + + it("clears the key when the new name matches no analyte", async () => { + const created = await post({ name: "Harnstoff", unit: "mg/dL" }); + const { data: marker } = (await created.json()) as { + data: { id: string; analyteKey: string | null }; + }; + expect(marker.analyteKey).toBe("urea"); + const res = await patch(marker.id, { name: "Harnstoff (eigene Notiz)" }); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { analyteKey: string | null } }; + expect(body.data.analyteKey).toBeNull(); + }); + + it("re-derives the key when the unit changes on a marker without readings", async () => { + const created = await post({ name: "Glukose", unit: "mEq/L" }); + const { data: marker } = (await created.json()) as { + data: { id: string; analyteKey: string | null }; + }; + expect(marker.analyteKey).toBeNull(); + const res = await patch(marker.id, { unit: "mg/dL" }); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { analyteKey: string | null } }; + expect(body.data.analyteKey).toBe("fasting-glucose"); + }); + + it("leaves the key alone on an edit that touches neither name nor unit", async () => { + const created = await post({ name: "Kalzium gesamt", unit: "mmol/L" }); + const { data: marker } = (await created.json()) as { data: { id: string } }; + const res = await patch(marker.id, { lowerBound: 2.1 }); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { analyteKey: string | null } }; + expect(body.data.analyteKey).toBe("calcium-total"); + }); +}); + describe("ensureAnalyteKey", () => { it("fills a null key once", async () => { const marker = await prisma.biomarker.create({ @@ -113,7 +220,12 @@ describe("ensureAnalyteKey", () => { ).toBe("uric-acid"); }); - it("never replaces a key that is set, even when the name would match another", async () => { + // This used to pin the opposite: a set key was never replaced. With no + // manual choice of analyte, a key that disagrees with the name can only be + // left over from a rename (or written in an older form), and keeping it + // converted readings with the wrong analyte's factor. So the key follows the + // name. + it("replaces a key that disagrees with the marker's name", async () => { const marker = await prisma.biomarker.create({ data: { userId: USER, @@ -122,16 +234,47 @@ describe("ensureAnalyteKey", () => { analyteKey: "hba1c", }, }); - // The caller read the row with the key set. - expect(await ensureAnalyteKey(prisma, marker)).toBe("hba1c"); - // A caller holding a stale copy with a null key does not overwrite either. - expect( - await ensureAnalyteKey(prisma, { ...marker, analyteKey: null }), - ).toBe("hba1c"); + expect(await ensureAnalyteKey(prisma, marker)).toBe("fasting-glucose"); expect( (await prisma.biomarker.findUnique({ where: { id: marker.id } })) ?.analyteKey, - ).toBe("hba1c"); + ).toBe("fasting-glucose"); + }); + + it("clears a key when the name matches no analyte", async () => { + const marker = await prisma.biomarker.create({ + data: { + userId: USER, + name: "Some other marker", + unit: "mg/dL", + analyteKey: "fasting-glucose", + }, + }); + expect(await ensureAnalyteKey(prisma, marker)).toBeNull(); + expect( + (await prisma.biomarker.findUnique({ where: { id: marker.id } })) + ?.analyteKey, + ).toBeNull(); + }); + + it("re-reads the row when the caller's copy is stale", async () => { + const marker = await prisma.biomarker.create({ + data: { + userId: USER, + name: "Urea", + unit: "mg/dL", + analyteKey: "urea", + }, + }); + // A copy read before the key was set: the gated write misses, the row as it + // stands already agrees, and nothing is overwritten. + expect( + await ensureAnalyteKey(prisma, { ...marker, analyteKey: null }), + ).toBe("urea"); + expect( + (await prisma.biomarker.findUnique({ where: { id: marker.id } })) + ?.analyteKey, + ).toBe("urea"); }); it("converges when two callers fill the same marker", async () => { 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 07/12] 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(); From cd1449e63e964d8ee42eb47310bd5d42e30929f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 14 Oct 2026 19:15:33 +0200 Subject: [PATCH 08/12] fix(labs): keep a reading's conversion record unless its value actually changes The single-reading edit cleared sourceValue and sourceUnit whenever the body carried a value, and the web edit sheet always sends one, so a date or note edit erased the "converted from" hint. It is now cleared only when the value differs from the stored one. The sheet also opened a converted reading at its stored precision (100.90191999999999); it now starts from the value as the lab surfaces show it and leaves an untouched value out of the request, so the stored full-precision number is not replaced by its rounded display. --- prisma/schema.prisma | 2 +- src/app/api/labs/[id]/route.ts | 10 +++- src/components/labs/lab-history-list.tsx | 19 ++++++-- src/lib/labs/__tests__/value-update.test.ts | 48 +++++++++++++++++++ src/lib/labs/value-update.ts | 29 +++++++++++ .../integration/labs-unit-conversion.test.ts | 19 +++++++- 6 files changed, 118 insertions(+), 9 deletions(-) create mode 100644 src/lib/labs/__tests__/value-update.test.ts create mode 100644 src/lib/labs/value-update.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 20b178b71..e5c4ab096 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -6152,7 +6152,7 @@ model LabResult { sourceReferenceText String? @map("source_reference_text") /// @pending-consumer(#1095) v1.42 — the value as printed on the source /// report, kept when a write converted it into the biomarker's canonical - /// unit. NULL when no conversion happened. A later edit of `value` clears it. + /// unit. NULL when no conversion happened. An edit that changes `value` clears it. sourceValue Float? @map("source_value") /// @pending-consumer(#1095) v1.42 — the unit the source report printed, /// paired with `sourceValue`. diff --git a/src/app/api/labs/[id]/route.ts b/src/app/api/labs/[id]/route.ts index 6ef7f3d77..e8363b6e5 100644 --- a/src/app/api/labs/[id]/route.ts +++ b/src/app/api/labs/[id]/route.ts @@ -195,8 +195,14 @@ export const PUT = apiHandler( if (d.value !== undefined) { data.value = d.value; // A number the person typed is no longer the conversion of what the - // report printed, so the "converted from" record would now be wrong. - if (existing.sourceValue !== null || existing.sourceUnit !== null) { + // report printed, so the "converted from" record would now be wrong. Only + // a CHANGED value says so: a client that sends the whole form back with + // the value untouched (the web edit dialog does) must not erase the + // record. Both sides are plain numbers (`value` is a Float column). + if ( + d.value !== existing.value && + (existing.sourceValue !== null || existing.sourceUnit !== null) + ) { data.sourceValue = null; data.sourceUnit = null; } diff --git a/src/components/labs/lab-history-list.tsx b/src/components/labs/lab-history-list.tsx index 6e581f259..109d32de6 100644 --- a/src/components/labs/lab-history-list.tsx +++ b/src/components/labs/lab-history-list.tsx @@ -32,6 +32,7 @@ import { localizedApiError } from "@/lib/api/localized-error"; import { formatLabReading } from "@/lib/labs/format-value"; import { LabConvertedNote } from "./lab-converted-note"; import { resolveNoteForUpdate } from "@/lib/labs/note-update"; +import { resolveValueForUpdate, valueEditSeed } from "@/lib/labs/value-update"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; @@ -177,7 +178,8 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) { const updateMutation = useMutation({ mutationFn: (body: { id: string; - // Exactly one of value / valueText is set, matching the row's type. + // At most one of value / valueText is set, matching the row's type; + // null leaves the field out of the PUT (an untouched value). value: number | null; valueText: string | null; takenAt: string; @@ -219,7 +221,7 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) { async function openEdit(reading: LabResultDto) { const seed = { - value: reading.value !== null ? String(reading.value) : "", + value: reading.value !== null ? valueEditSeed(reading.value) : "", valueText: reading.valueText ?? "", takenAt: toDateTimeLocal(reading.takenAt), note: "", @@ -275,11 +277,20 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) { } qualitativeValue = trimmed; } else { - numericValue = parseDecimal(editValue); - if (numericValue === null) { + const parsed = parseDecimal(editValue); + if (parsed === null) { setEditError(t("labs.form.requiredError")); return; } + // An untouched field is left out of the PUT (null here), so the stored + // full-precision value and a conversion record survive a date or note + // edit. + numericValue = + resolveValueForUpdate({ + seed: editSeed.value, + editorValue: editValue, + parsed, + }) ?? null; } const takenDate = new Date(editTakenAt); if (Number.isNaN(takenDate.getTime())) { diff --git a/src/lib/labs/__tests__/value-update.test.ts b/src/lib/labs/__tests__/value-update.test.ts new file mode 100644 index 000000000..dcc98d4c8 --- /dev/null +++ b/src/lib/labs/__tests__/value-update.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; + +import { resolveValueForUpdate, valueEditSeed } from "../value-update"; + +describe("valueEditSeed", () => { + it("opens a converted reading at display precision, not its stored tail", () => { + // 5.6 mmol/L of glucose, stored in mg/dL. + expect(valueEditSeed(100.90191999999999)).toBe("100.9"); + }); + + it("keeps whole numbers bare and at most two decimals", () => { + expect(valueEditSeed(110)).toBe("110"); + expect(valueEditSeed(4.567)).toBe("4.57"); + }); +}); + +describe("resolveValueForUpdate", () => { + it("leaves an untouched value out of the update", () => { + expect( + resolveValueForUpdate({ + seed: "100.9", + editorValue: "100.9", + parsed: 100.9, + }), + ).toBeUndefined(); + }); + + it("sends a changed value", () => { + expect( + resolveValueForUpdate({ + seed: "100.9", + editorValue: "101,5", + parsed: 101.5, + }), + ).toBe(101.5); + }); + + it("sends a value typed back to the same number in another spelling", () => { + // "100,9" is a deliberate re-entry; the stored tail goes, as for any edit. + expect( + resolveValueForUpdate({ + seed: "100.9", + editorValue: "100,9", + parsed: 100.9, + }), + ).toBe(100.9); + }); +}); diff --git a/src/lib/labs/value-update.ts b/src/lib/labs/value-update.ts new file mode 100644 index 000000000..5cbbad305 --- /dev/null +++ b/src/lib/labs/value-update.ts @@ -0,0 +1,29 @@ +import { formatLabValue } from "./format-value"; + +/** + * The numeric value of a lab reading in the edit sheet. + * + * A converted reading is stored at full precision (5.6 mmol/L of glucose is + * 100.90191999999999 mg/dL), and the sheet must not open with that tail. The + * field starts from the value as every lab surface shows it: whole numbers + * bare, at most two decimals. Written with a dot, which the sheet's parser + * reads, rather than in the reader's locale, which an input cannot round-trip. + */ +export function valueEditSeed(value: number): string { + return formatLabValue(value, String); +} + +/** + * What the edit PUT sends for the value. A field the person did not touch is + * omitted (`undefined`), so the stored full-precision number stays as it is and + * a converted reading keeps its "converted from" record: sending the rounded + * start value back would both lose precision and read to the server as a new + * number. A changed field sends the parsed number. + */ +export function resolveValueForUpdate(args: { + seed: string; + editorValue: string; + parsed: number; +}): number | undefined { + return args.editorValue.trim() === args.seed.trim() ? undefined : args.parsed; +} diff --git a/tests/integration/labs-unit-conversion.test.ts b/tests/integration/labs-unit-conversion.test.ts index 9634476ac..89af81ce8 100644 --- a/tests/integration/labs-unit-conversion.test.ts +++ b/tests/integration/labs-unit-conversion.test.ts @@ -14,7 +14,8 @@ * the printed unit is in range after conversion; * - the OCR commit converts, and the same scan committed again is a duplicate * (the stored number is converted, so the printed one is what matches); - * - editing the value of a converted reading clears the conversion record; + * - changing the value of a converted reading clears the conversion record, + * and an edit that sends the value back unchanged keeps it; * - the list returns the fields. */ import { NextRequest } from "next/server"; @@ -338,10 +339,24 @@ describe("editing and reading back", () => { takenAt: "2026-07-02T08:00:00.000Z", }); const id = created.json.data!.id; - const edited = await call("one", "PUT", { note: "fasting" }, id); + const storedValue = created.json.data!.value; + // The web edit dialog sends the form back whole, the unchanged value + // included; only a changed value clears the record. + const edited = await call( + "one", + "PUT", + { + value: storedValue, + takenAt: "2026-07-02T09:00:00.000Z", + note: "fasting", + }, + id, + ); expect(edited.status).toBe(200); + expect(edited.json.data!.value).toBe(storedValue); expect(edited.json.data!.converted).toBe(true); expect(edited.json.data!.sourceValue).toBe(6.1); + expect(edited.json.data!.sourceUnit).toBe("mmol/L"); }); it("returns the fields on the list and on the single reading", async () => { From 07f09e1b610cae07bd3ba7a9dbbafa06c8ab21f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 14 Oct 2026 19:18:49 +0200 Subject: [PATCH 09/12] fix(labs): store the catalog slug as a marker's analyte key The matcher stored the registry entry's key, so LDL and HDL markers were recorded as total-cholesterol. The key now names what the marker is (the catalog slug, or a registry-only key for an analyte with no seed), and the registry resolves ldl and hdl to the cholesterol entry for the conversion as before. A key already written in the old form is rewritten by the same fill step that brings a stale key in line with its name, the first time a reading needs it. The OpenAPI descriptions say what the key is and that it follows the marker's name and unit. --- docs/api/openapi.yaml | 20 +++++---- src/components/labs/ocr-review-types.ts | 2 +- src/components/labs/types.ts | 2 +- src/lib/labs/__tests__/analyte-match.test.ts | 19 ++++++--- src/lib/labs/analyte-match.ts | 22 +++++----- src/lib/labs/biomarker-catalog.ts | 6 ++- src/lib/labs/serialise.ts | 2 +- src/lib/openapi/routes/biomarkers.ts | 2 +- src/lib/openapi/routes/ocr.ts | 2 +- src/lib/validations/labs-ocr.ts | 2 +- tests/integration/labs-analyte-key.test.ts | 45 ++++++++++++++++++++ 11 files changed, 91 insertions(+), 33 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index c45800cf0..27d693a21 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -68258,9 +68258,9 @@ components: anyOf: - type: string - type: "null" - description: The analyte the matched marker is (the unit-conversion registry's key, e.g. `fasting-glucose`), or null - when the marker does not exist yet or its name matches none. With `markerUnit` it tells a client whether a - row in another unit can be converted on commit. + description: The analyte the matched marker is (the catalog slug, e.g. `fasting-glucose` or `ldl`, or a registry-only + key such as `urea`), or null when the marker does not exist yet or its name matches none. With `markerUnit` + it tells a client whether a row in another unit can be converted on commit. duplicateOf: anyOf: - type: string @@ -68439,12 +68439,14 @@ components: - createdAt - updatedAt additionalProperties: false - description: A user-scoped catalog marker. `context` is the decrypted per-marker note (or null); `hasContext` flags its - presence. `analyteKey` is the analyte the marker was recognised as (the unit-conversion registry's key, e.g. - `fasting-glucose`), or null when its name matches none; a reading in another unit is converted only for a marker - that has one. `hidden` drops the marker from the active catalog list and the lab-entry pickers without deleting - it (its readings and unit/range survive). A `LabResult` linking this marker resolves its unit + reference bounds - from here. + description: "A user-scoped catalog marker. `context` is the decrypted per-marker note (or null); `hasContext` flags its + presence. `analyteKey` is the analyte the marker was recognised as: the catalog slug its name belongs to (e.g. + `fasting-glucose`, `ldl`, `hdl`), or a key of the unit-conversion registry for an analyte with no catalog seed + (e.g. `urea`, `uric-acid`), or null when its name matches none. It is derived from `name` and `unit`, never + chosen: it is set on create and re-derived (or cleared) when an update changes either. A reading in another unit + is converted only for a marker that has one. `hidden` drops the marker from the active catalog list and the + lab-entry pickers without deleting it (its readings and unit/range survive). A `LabResult` linking this marker + resolves its unit + reference bounds from here." CreateBiomarkerResponse: type: object properties: diff --git a/src/components/labs/ocr-review-types.ts b/src/components/labs/ocr-review-types.ts index c05b394b2..9a167ff2f 100644 --- a/src/components/labs/ocr-review-types.ts +++ b/src/components/labs/ocr-review-types.ts @@ -30,7 +30,7 @@ export interface OcrReviewRow { * Save when it can be (see `readingConversion`) and cannot be saved when not. */ markerUnit: string | null; - /** Which analyte the matched marker is (conversion registry key), or null. */ + /** Which analyte the matched marker is (catalog slug or registry-only key), or null. */ markerAnalyteKey: string | null; /** Non-null when the server flagged a likely duplicate of a live reading. */ duplicateOf: string | null; diff --git a/src/components/labs/types.ts b/src/components/labs/types.ts index 1fbfc6f44..90e7f60fb 100644 --- a/src/components/labs/types.ts +++ b/src/components/labs/types.ts @@ -75,7 +75,7 @@ export interface BiomarkerDto { panel: string | null; /** * Which analyte the marker is, for converting a reading in another unit - * (the unit-conversion registry's key), or null when it is none that is known. + * (the catalog slug, e.g. `ldl`, or a registry-only key such as `urea`), or null when it is none that is known. */ analyteKey: string | null; hasContext: boolean; diff --git a/src/lib/labs/__tests__/analyte-match.test.ts b/src/lib/labs/__tests__/analyte-match.test.ts index 28cc86302..56668108d 100644 --- a/src/lib/labs/__tests__/analyte-match.test.ts +++ b/src/lib/labs/__tests__/analyte-match.test.ts @@ -26,15 +26,22 @@ describe("the catalog's own names, in every language", () => { ["Gesamtcholesterin", "mg/dL", "total-cholesterol"], ["Cholestérol total", "mmol/L", "total-cholesterol"], ["LDL cholesterol", "mg/dL", "ldl"], - ["LDL-Cholesterin", "mg/dL", "total-cholesterol"], - ["Colesterolo HDL", "mg/dL", "total-cholesterol"], + ["LDL-Cholesterin", "mg/dL", "ldl"], + ["Colesterolo HDL", "mg/dL", "hdl"], ["Vitamin D (25-OH)", "ng/mL", "vitamin-d"], ["Créatinine", "mg/dL", "creatinine"], ["Kreatynina", "µmol/L", "creatinine"], ])("%s (%s) is %s", (name, unit, key) => { - // LDL and HDL are aliases of cholesterol: the registry reports one key. - const expected = analyteConversionFor(key)!.key; - expect(match(name, unit)).toBe(expected); + // The key is the catalog slug the name belongs to, so LDL and HDL keep + // their own identity even though they convert with the cholesterol entry. + expect(match(name, unit)).toBe(key); + }); + + it("resolves an LDL or HDL key to the cholesterol entry for the conversion", () => { + expect(match("LDL cholesterol", "mg/dL")).toBe("ldl"); + expect(analyteConversionFor("ldl")?.key).toBe("total-cholesterol"); + expect(analyteConversionFor("hdl")?.key).toBe("total-cholesterol"); + expect(analyteAcceptsUnit("ldl", "mmol/L")).toBe(true); }); it("recognises a seed typed without the app's gloss", () => { @@ -65,7 +72,7 @@ describe("typed names", () => { it("folds case, accents, hyphens and spacing the same on both sides", () => { expect(match(" HARNSÄURE ", "mg/dL")).toBe("uric-acid"); - expect(match("ldl-c", "mg/dL")).toBe(analyteConversionFor("ldl")!.key); + expect(match("ldl-c", "mg/dL")).toBe("ldl"); expect(match("blood urea-nitrogen", "mg/dL")).toBe("blood-urea-nitrogen"); }); }); diff --git a/src/lib/labs/analyte-match.ts b/src/lib/labs/analyte-match.ts index 0e04d48f3..2e7338ad4 100644 --- a/src/lib/labs/analyte-match.ts +++ b/src/lib/labs/analyte-match.ts @@ -12,8 +12,12 @@ * - the marker's own unit must be one the analyte reaches, so a marker called * "Glucose" in a unit the registry cannot convert is left unlinked. * - * Returns the registry key, or null. Pure apart from reading the message - * bundles once. + * Returns the catalog slug the name was recognised as (`ldl`, `hdl`, + * `fasting-glucose`), or the registry-only key for an analyte with no seed + * (`urea`, `uric-acid`), or null. LDL stays `ldl` even though the registry + * converts it with the cholesterol entry: the key says which marker this is, + * and `analyteConversionFor` resolves it to its entry. Pure apart from reading + * the message bundles once. */ import { foldForMatch } from "@/lib/i18n/fold-for-match"; import { localisedValues } from "@/lib/i18n/shared-resolve"; @@ -30,7 +34,7 @@ function withoutGloss(folded: string): string { let cache: ReadonlyMap | null = null; /** - * folded name -> registry key, with every name two analytes claim removed. + * folded name -> analyte key, with every name two keys claim removed. * Exported for the guard test, which asserts nothing was dropped silently. */ export function analyteNameIndex(): { @@ -47,18 +51,16 @@ export function analyteNameIndex(): { }; for (const seed of BIOMARKER_CATALOG) { - const entry = analyteConversionFor(seed.slug); - if (!entry) continue; + if (!analyteConversionFor(seed.slug)) continue; for (const name of localisedValues(`labs.catalog.${seed.slug}`)) { - claim(name, entry.key); + claim(name, seed.slug); // The bare name too, for a marker typed without the app's gloss. - claim(withoutGloss(foldForMatch(name)), entry.key); + claim(withoutGloss(foldForMatch(name)), seed.slug); } } for (const { key, names } of ANALYTE_ALIASES) { - const entry = analyteConversionFor(key); - if (!entry) continue; - for (const name of names) claim(name, entry.key); + if (!analyteConversionFor(key)) continue; + for (const name of names) claim(name, key); } const index = new Map(); diff --git a/src/lib/labs/biomarker-catalog.ts b/src/lib/labs/biomarker-catalog.ts index 3bcf120af..376a63b6a 100644 --- a/src/lib/labs/biomarker-catalog.ts +++ b/src/lib/labs/biomarker-catalog.ts @@ -279,7 +279,9 @@ export const BIOMARKER_PANELS = [ /** * Names a marker may carry for an analyte the unit-conversion registry knows - * (`src/lib/labs/unit-conversion/registry.ts`), keyed by the registry's key. + * (`src/lib/labs/unit-conversion/registry.ts`), keyed by the analyte key a + * marker stores: the catalog slug (`ldl`, `hdl`, `fasting-glucose`), or a + * registry-only key for an analyte with no seed. * * A `Biomarker` row keeps only its name, not the seed it came from, so this is * how a marker is told which analyte it is: its folded name equals one of these, @@ -296,7 +298,7 @@ export const BIOMARKER_PANELS = [ * names live only here. */ export interface AnalyteAliases { - /** The registry key of the analyte. */ + /** The analyte key: a catalog slug, or a registry-only key. */ key: string; names: readonly string[]; } diff --git a/src/lib/labs/serialise.ts b/src/lib/labs/serialise.ts index aa68ed09a..20d93ed7d 100644 --- a/src/lib/labs/serialise.ts +++ b/src/lib/labs/serialise.ts @@ -57,7 +57,7 @@ export interface ResolvedBiomarker { lowerBound: number | null; upperBound: number | null; panel: string | null; - /** Which analyte the marker is (unit-conversion registry key), if known. */ + /** Which analyte the marker is (catalog slug or registry-only key), if known. */ analyteKey?: string | null; } diff --git a/src/lib/openapi/routes/biomarkers.ts b/src/lib/openapi/routes/biomarkers.ts index 6ea98d8c8..9b8fabf2b 100644 --- a/src/lib/openapi/routes/biomarkers.ts +++ b/src/lib/openapi/routes/biomarkers.ts @@ -63,7 +63,7 @@ const biomarkerRow = z .meta({ id: "Biomarker", description: - "A user-scoped catalog marker. `context` is the decrypted per-marker note (or null); `hasContext` flags its presence. `analyteKey` is the analyte the marker was recognised as (the unit-conversion registry's key, e.g. `fasting-glucose`), or null when its name matches none; a reading in another unit is converted only for a marker that has one. `hidden` drops the marker from the active catalog list and the lab-entry pickers without deleting it (its readings and unit/range survive). A `LabResult` linking this marker resolves its unit + reference bounds from here.", + "A user-scoped catalog marker. `context` is the decrypted per-marker note (or null); `hasContext` flags its presence. `analyteKey` is the analyte the marker was recognised as: the catalog slug its name belongs to (e.g. `fasting-glucose`, `ldl`, `hdl`), or a key of the unit-conversion registry for an analyte with no catalog seed (e.g. `urea`, `uric-acid`), or null when its name matches none. It is derived from `name` and `unit`, never chosen: it is set on create and re-derived (or cleared) when an update changes either. A reading in another unit is converted only for a marker that has one. `hidden` drops the marker from the active catalog list and the lab-entry pickers without deleting it (its readings and unit/range survive). A `LabResult` linking this marker resolves its unit + reference bounds from here.", }); const listResponse = z diff --git a/src/lib/openapi/routes/ocr.ts b/src/lib/openapi/routes/ocr.ts index 74d8e4abd..493078b4f 100644 --- a/src/lib/openapi/routes/ocr.ts +++ b/src/lib/openapi/routes/ocr.ts @@ -79,7 +79,7 @@ const extractedRow = z }), markerAnalyteKey: z.string().nullable().meta({ description: - "The analyte the matched marker is (the unit-conversion registry's key, e.g. `fasting-glucose`), or null when the marker does not exist yet or its name matches none. With `markerUnit` it tells a client whether a row in another unit can be converted on commit.", + "The analyte the matched marker is (the catalog slug, e.g. `fasting-glucose` or `ldl`, or a registry-only key such as `urea`), or null when the marker does not exist yet or its name matches none. With `markerUnit` it tells a client whether a row in another unit can be converted on commit.", }), duplicateOf: z.string().nullable(), }) diff --git a/src/lib/validations/labs-ocr.ts b/src/lib/validations/labs-ocr.ts index d19bb0f4e..8e11aa7d9 100644 --- a/src/lib/validations/labs-ocr.ts +++ b/src/lib/validations/labs-ocr.ts @@ -132,7 +132,7 @@ export interface OcrExtractedRowDto { */ markerUnit: string | null; /** - * Which analyte the matched marker is (unit-conversion registry key), or null + * Which analyte the matched marker is (catalog slug or registry-only key), or null * when the marker is new or none is known. With `markerUnit` it tells the * review screen whether a row in another unit can be converted on Save. */ diff --git a/tests/integration/labs-analyte-key.test.ts b/tests/integration/labs-analyte-key.test.ts index 595c70261..0e17970ff 100644 --- a/tests/integration/labs-analyte-key.test.ts +++ b/tests/integration/labs-analyte-key.test.ts @@ -88,6 +88,33 @@ describe("POST /api/biomarkers", () => { expect(data.analyteKey).toBe("total-cholesterol"); }); + it("stores the catalog slug for LDL and HDL, and converts them as cholesterol", async () => { + for (const [name, key] of [ + ["LDL cholesterol", "ldl"], + ["HDL-Cholesterin", "hdl"], + ]) { + const res = await post({ name, unit: "mg/dL" }); + const { data } = (await res.json()) as { data: { analyteKey: string } }; + expect(data.analyteKey, name).toBe(key); + } + const ldl = await prisma.biomarker.findFirstOrThrow({ + where: { userId: USER, name: "LDL cholesterol" }, + }); + const reading = await postReading({ + biomarkerId: ldl.id, + value: 3, + unit: "mmol/L", + takenAt: "2026-05-04T08:00:00.000Z", + }); + expect(reading.status).toBe(201); + const { data: dto } = (await reading.json()) as { + data: { value: number; converted: boolean }; + }; + expect(dto.converted).toBe(true); + // 3 mmol/L x 38.6664 (C27H46O, 386.664 g/mol). + expect(dto.value.toFixed(2)).toBe("116.00"); + }); + it("leaves a free name, and a known name in an unreachable unit, unlinked", async () => { for (const [name, unit] of [ ["My own marker", "mg/dL"], @@ -277,6 +304,24 @@ describe("ensureAnalyteKey", () => { ).toBe("urea"); }); + it("corrects a key stored as the registry entry rather than the catalog slug", async () => { + // Before the slug was stored, an LDL marker got the cholesterol entry's + // key. Same conversion, wrong identity; the fill step rewrites it once. + const marker = await prisma.biomarker.create({ + data: { + userId: USER, + name: "LDL-Cholesterin", + unit: "mg/dL", + analyteKey: "total-cholesterol", + }, + }); + expect(await ensureAnalyteKey(prisma, marker)).toBe("ldl"); + const row = await prisma.biomarker.findUnique({ where: { id: marker.id } }); + expect(row?.analyteKey).toBe("ldl"); + // And a second run changes nothing. + expect(await ensureAnalyteKey(prisma, row!)).toBe("ldl"); + }); + it("converges when two callers fill the same marker", async () => { const marker = await prisma.biomarker.create({ data: { userId: USER, name: "Kreatinin", unit: "mg/dL" }, From 350fd1b767bf4b156b04f23399fa482af6c5e1c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 14 Oct 2026 19:19:15 +0200 Subject: [PATCH 10/12] fix(labs): name calcifediol as the source of the vitamin D factor The note said the factor was taken from cholecalciferol (C27H44O2). Vitamin D3 is C27H44O; C27H44O2 is 25-hydroxyvitamin D3 (calcifediol), the form the assay reports and the one whose molar mass gives 1 ng/mL = 2.496 nmol/L. The factor was right; the note now says what it rests on. --- .../__tests__/registry.test.ts | 19 ++++++++++++++++++- src/lib/labs/unit-conversion/registry.ts | 7 ++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/lib/labs/unit-conversion/__tests__/registry.test.ts b/src/lib/labs/unit-conversion/__tests__/registry.test.ts index 724a7f8b4..21c9d3d33 100644 --- a/src/lib/labs/unit-conversion/__tests__/registry.test.ts +++ b/src/lib/labs/unit-conversion/__tests__/registry.test.ts @@ -28,7 +28,7 @@ const MOLAR_MASS: Record = { "uric-acid": 168.112, // C5H4N4O3 "calcium-total": 40.078, // Ca "bilirubin-total": 584.673, // C33H36N4O6 - "vitamin-d": 400.647, // C27H44O2 + "vitamin-d": 400.647, // C27H44O2, 25-hydroxyvitamin D3 (calcifediol) }; /** Of the entry's amount unit: how many of its mass unit one of it is, per g/mol. */ @@ -44,6 +44,23 @@ const POWER_OF_TEN: Record = { "vitamin-d": 1 / 1_000, // nmol/L -> ng/mL }; +describe("the vitamin D entry", () => { + const entry = analyteConversionFor("vitamin-d")!; + + it("names the molecule its factor comes from: calcifediol, not vitamin D3", () => { + // Cholecalciferol is C27H44O; the factor uses C27H44O2, which is the + // 25-hydroxy form a lab reports. The note must say which one it is. + expect(entry.source).toContain("calcifediol"); + expect(entry.source).toContain("C27H44O2"); + expect(entry.source).not.toMatch(/cholecalciferol \(C27H44O2\)/); + }); + + it("converts 1 ng/mL to 2.496 nmol/L", () => { + // ng/mL is the hub; nmol/L maps to it by 400.647 / 1000. + expect(1 / entry.units["nmol/L"].a).toBeCloseTo(2.496, 3); + }); +}); + describe("the registry's entries", () => { const entries = allAnalyteConversions(); diff --git a/src/lib/labs/unit-conversion/registry.ts b/src/lib/labs/unit-conversion/registry.ts index 2ec1e2ee1..37148c03c 100644 --- a/src/lib/labs/unit-conversion/registry.ts +++ b/src/lib/labs/unit-conversion/registry.ts @@ -187,7 +187,12 @@ const ENTRIES: readonly AnalyteConversion[] = [ formula: { C: 27, H: 44, O: 2 }, mass: "ng/mL", amount: "nmol/L", - note: "25-hydroxyvitamin D, taken as cholecalciferol (C27H44O2).", + // What a lab measures and reports is 25-hydroxyvitamin D (calcifediol), + // so its molar mass sets the factor: 1 ng/mL is 2.496 nmol/L. Vitamin D3 + // itself (cholecalciferol, C27H44O) is lighter by the one oxygen atom. + note: + "25-hydroxyvitamin D3 (calcifediol, C27H44O2), the form the assay reports; " + + "1 ng/mL = 2.496 nmol/L.", }), defineAnalyteConversion({ key: "hba1c", From eabbe3e0323a562c508513408347e98b92994898 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 14 Oct 2026 19:19:44 +0200 Subject: [PATCH 11/12] fix(labs): let the conversion badge in the scan review wrap At 390 px the badge truncated, and the part it cut off was the value the report printed. It now wraps inside the row like a sentence. --- .../labs/__tests__/ocr-row-editor.test.tsx | 12 ++++++++++++ src/components/labs/ocr-row-editor.tsx | 6 ++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/components/labs/__tests__/ocr-row-editor.test.tsx b/src/components/labs/__tests__/ocr-row-editor.test.tsx index aef017711..e28f27093 100644 --- a/src/components/labs/__tests__/ocr-row-editor.test.tsx +++ b/src/components/labs/__tests__/ocr-row-editor.test.tsx @@ -176,6 +176,18 @@ describe(" unit differs but converts", () => { expect(html).not.toContain('aria-invalid="true"'); }); + it("wraps the sentence instead of cutting off the printed value", () => { + const html = render(undefined, "en", converts); + const badge = + /]*data-slot="ocr-row-converted-badge"[^>]*>([\s\S]*?)<\/span><\/span>/.exec( + html, + ); + expect(badge).not.toBeNull(); + const tag = badge![0].slice(0, badge![0].indexOf(">")); + expect(tag).toContain("whitespace-normal"); + expect(badge![0]).not.toContain("truncate"); + }); + it("speaks German too", () => { const html = render(undefined, "de", converts); expect(html).toContain("umgerechnet aus 5,6 mmol/L"); diff --git a/src/components/labs/ocr-row-editor.tsx b/src/components/labs/ocr-row-editor.tsx index 338c90a85..221906e5f 100644 --- a/src/components/labs/ocr-row-editor.tsx +++ b/src/components/labs/ocr-row-editor.tsx @@ -191,11 +191,13 @@ export function OcrRowEditor({ {conversion ? ( - + {t("labs.ocr.unitConvertedBadge", { value: labNumber(conversion.value), markerUnit: conversion.unit, From ec57ae10b12298afce2ba3629027f20625715577 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 14 Oct 2026 19:20:01 +0200 Subject: [PATCH 12/12] docs(labs): describe the registry listing by what uses it Rewords a code comment that referred to the work by its planning step, and a loop counter's comment, to say what the code does. --- src/lib/labs/analyte-key-store.ts | 4 ++-- src/lib/labs/unit-conversion/registry.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib/labs/analyte-key-store.ts b/src/lib/labs/analyte-key-store.ts index dfefa416d..e2b1a12bc 100644 --- a/src/lib/labs/analyte-key-store.ts +++ b/src/lib/labs/analyte-key-store.ts @@ -30,10 +30,10 @@ export async function ensureAnalyteKey( marker: MarkerKeyState, ): Promise { let current: MarkerKeyState = marker; - // Two rounds: the caller's copy, then the row as it stands if that copy was + // Two attempts: the caller's copy, then the row as it stands if that copy was // stale. A third writer racing both is vanishingly rare; the derived key is // still the right answer for the name this call saw. - for (let round = 0; round < 2; round++) { + for (let attempt = 0; attempt < 2; attempt++) { const derived = matchAnalyteKey({ name: current.name, unit: current.unit }); const stored = current.analyteKey ?? null; if (stored === derived) return derived; diff --git a/src/lib/labs/unit-conversion/registry.ts b/src/lib/labs/unit-conversion/registry.ts index 37148c03c..675c175e7 100644 --- a/src/lib/labs/unit-conversion/registry.ts +++ b/src/lib/labs/unit-conversion/registry.ts @@ -230,7 +230,7 @@ export function analyteConversionFor( return analyteKey ? (BY_KEY.get(analyteKey) ?? null) : null; } -/** Every entry, once each, for the guard test and for phase 3's key matching. */ +/** Every entry, once each, for the guard test and the analyte key matching. */ export function allAnalyteConversions(): readonly AnalyteConversion[] { return ENTRIES; }