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