mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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
99 lines
3.3 KiB
TypeScript
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);
|
|
}
|