Files
HealthLog/src/lib/labs/unit-normalise.ts
T
BernhardBuckel d3916036c2 fix(labs): refuse a reading whose unit differs from its marker's
A numeric lab reading was stored with its value as received and the marker's
unit stamped on it, so 5.6 mmol/L against a marker kept in mg/dL became
5.6 mg/dL and read as far below range. Both the manual/API write and the
scan commit did this; nothing compared the two units.

- POST /api/labs returns 422 labs.unit.mismatch, with the marker's unit and
  the reading's unit in meta, and writes nothing. The unit is compared only
  when one was sent: on the biomarkerId path it may be omitted, and the
  marker's unit is then the stated one. A qualitative reading has no unit.
- The scan commit skips such a row with reason unit_mismatch, so the outcome
  reads partial or failed instead of a clean save, and the toast names the
  analytes concerned rather than saying "duplicates".
- The extract response carries the matched marker's unit. The review screen
  shows a calm badge on a row in another unit, starts it unticked like a
  duplicate, and treats it as a blocking error on the unit field if it is
  ticked anyway.
- A small lab-specific normaliser forgives spelling only: spacing, the micro
  prefix, the case of the litre, and a unit printed in capitals for a short
  list where capitals cannot mean another unit. Case is otherwise significant,
  so mIU/L and MIU/L, or g/L and G/L, are never merged.

The document-commit path already refuses a mismatch and is left as it is.
Readings stored wrongly before this cannot be identified, because the
original unit was never kept; they are not touched.

Adds EN and DE strings; the other locales are left to the maintainers. The
OpenAPI spec and the error-code catalogue carry the new code.

Fixes #1094
2026-10-03 15:44:51 +02:00

99 lines
3.3 KiB
TypeScript

/**
* Spelling-level comparison of lab units.
*
* A reading may only be stored under a marker whose unit it actually is in, so
* the write paths compare the unit a reading arrives with against the unit the
* marker is tracked in. This module answers one narrow question: are these two
* strings the same unit written two ways?
*
* It resolves spelling, never magnitude. Two spellings are treated as equal
* only where no reading of either could mean a different quantity:
*
* - whitespace around the slash (`mg / dL`),
* - the micro prefix (`µg/L`, `μg/L` with the Greek mu, `ug/L`, `mcg/L`),
* - the case of the litre in the denominator (`mmol/l`, `mg/dl`, `mg/DL`),
* - a unit printed entirely in capitals (`MG/DL`, `MMOL/L`), but only for the
* short list of units where capitals cannot denote another real unit.
*
* Case is otherwise significant, on purpose. `mIU/L` and `MIU/L` differ by a
* factor of a billion (a lone capital M is mega, a lowercase m is milli), and
* `g/L` (grams) is not `G/L` (the 10^9/L of a blood count). A unit this module
* does not recognise is compared exactly, so an unfamiliar spelling surfaces as
* a mismatch rather than being guessed at.
*
* Pure and dependency-free.
*/
/** Units that may be recognised when printed entirely in capitals. */
const SHOUTED_UNITS: ReadonlyMap<string, string> = new Map(
[
"mg/dL",
"g/dL",
"ng/dL",
"µg/dL",
"mmol/L",
"µmol/L",
"nmol/L",
"pmol/L",
"ng/mL",
"pg/mL",
"µg/L",
"mEq/L",
"IU/L",
"U/L",
"fL",
].map((unit) => [unit.toLowerCase(), unit]),
);
const MICRO_SIGNS = /[µμ]/g;
/** What may follow a spelled-out micro prefix: g, mol, IU, U, L, kat, Eq. */
const MICRO_BEFORE = "(?=g|mol|IU|U|L|l|kat|Eq|eq)";
const MICRO_BEFORE_LOWER = "(?=g|mol|iu|u|l|kat|eq)";
function foldSpacing(raw: string): string {
return raw
.trim()
.replace(/\s+/g, " ")
.replace(/\s*\/\s*/g, "/");
}
function foldMicro(text: string, lookahead: string): string {
const unified = text.replace(MICRO_SIGNS, "µ");
// A spelled-out prefix counts only at the start of the unit or straight after
// a slash, and only in front of a unit that takes it. `u/L` (enzyme units)
// and `U/L` therefore stay as they are.
const prefix = new RegExp(`(^|/)(?:u|mc)${lookahead}`, "g");
return unified.replace(prefix, "$1µ");
}
/** `/l`, `/dl`, `/ml`, `/µl` in any case → `/L`, `/dL`, `/mL`, `/µL`. */
function foldLitre(text: string): string {
return text.replace(
/\/([dmµ]?)l$/i,
(_, prefix: string) => `/${prefix.toLowerCase()}L`,
);
}
/**
* The comparison form of a lab unit: the same string for every spelling that
* means the same thing, and the (trimmed) string itself otherwise.
*/
export function normaliseLabUnit(raw: string): string {
const spaced = foldSpacing(raw);
const hasLowercase = /[a-zµμ]/.test(spaced);
const hasUppercase = /[A-Z]/.test(spaced);
if (hasUppercase && !hasLowercase) {
const lowered = foldMicro(spaced.toLowerCase(), MICRO_BEFORE_LOWER);
const known = SHOUTED_UNITS.get(lowered);
if (known) return known;
}
return foldLitre(foldMicro(spaced, MICRO_BEFORE));
}
/** Whether two strings name the same lab unit (see the module comment). */
export function sameLabUnit(a: string, b: string): boolean {
return normaliseLabUnit(a) === normaliseLabUnit(b);
}