mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
A lab runs its own method on its own device, so the window a physician evaluates a value against is the one printed next to it on that report, not a catalog band averaged over every lab. Reading a report pulled the value and dropped that range: the extraction asked for it, the commit handed it to the biomarker resolver, and the resolver only uses it when it mints a NEW marker. For every analyte the user already had — the normal case after a first panel — the printed range was read and then thrown away. Add `sourceReferenceLow` / `sourceReferenceHigh` / `sourceReferenceText` to `LabResult` and carry the range from the report to the reading. The text is stored whether or not numbers can be read out of it, because "bis 5,0" and "negativ" state a window too; a range that could not be parsed is kept verbatim, never replaced with invented bounds. `resolveEffectiveReferenceRange` is the one place the precedence lives: the source window wins for that single reading, and the catalog stays the net for readings without one. A printed string with no derivable bound does not displace the catalog. Every surface that judges a reading routes through it — the API verdict, the doctor report, the insights blocks and per-marker assessment, the coach snapshot, the MCP read and the FHIR `referenceRange` — so a value cannot read in range on one surface and out on another. The API keeps `referenceLow` / `referenceHigh` as the window in force, so a client that reads only those two renders the right range and the right verdict with no change of its own; the catalog band, the source window, its origin and a divergence flag ride alongside for clients that want to show both. Read side: the chart draws the source window as a second, visually distinct band with the reading it came from named beneath it, the reading rows name the printed range and the catalog band where the two disagree, and the doctor-report PDF prints the report's own string with a footnote naming the saved range. Entry side: one optional field on the lab form, folded away until asked for, and the printed range is editable on the scan review screen. The range parser covers the notations German and English reports actually print — two-sided, one-sided, comma or dot decimals, thousands separators, trailing units — reads units without ever converting them, and refuses to guess on anything ambiguous.
174 lines
6.5 KiB
TypeScript
174 lines
6.5 KiB
TypeScript
/**
|
||
* v1.18.0 — single source of truth for lab reference-range classification
|
||
* and range-string structure.
|
||
*
|
||
* Three surfaces need the same logic: the API response (`rangeStatus`), the
|
||
* doctor-report PDF table, and the lab list card. They were drifting copies.
|
||
* The CLASSIFICATION (below / in-range / above / unknown) and the range
|
||
* STRUCTURE (`low–high` / `≤ high` / `≥ low` / empty) are shared here; the
|
||
* per-surface NUMBER FORMATTING stays a caller concern — the PDF wants a
|
||
* locale-aware one-decimal formatter, the list trims to int/2-dec — so
|
||
* `formatReferenceRange` takes a `formatNumber` callback and never imposes a
|
||
* formatter of its own.
|
||
*/
|
||
|
||
/**
|
||
* Reference-range classification. Deliberately a three-state, NEUTRAL
|
||
* verdict — the badge that renders it must stay calm and informative, NOT
|
||
* alarming (no red "out of range" tint). `"unknown"` when the lab reported
|
||
* no usable bounds.
|
||
*
|
||
* Bounds are treated as inclusive: a value exactly on the reference limit
|
||
* reads as in-range, matching how labs print "≤" / "≥" reference notation.
|
||
*/
|
||
export type ReferenceRangeStatus = "in-range" | "below" | "above" | "unknown";
|
||
|
||
export function classifyReferenceRange(
|
||
value: number,
|
||
referenceLow: number | null | undefined,
|
||
referenceHigh: number | null | undefined,
|
||
): ReferenceRangeStatus {
|
||
const hasLow = referenceLow !== null && referenceLow !== undefined;
|
||
const hasHigh = referenceHigh !== null && referenceHigh !== undefined;
|
||
if (!hasLow && !hasHigh) return "unknown";
|
||
if (hasLow && value < (referenceLow as number)) return "below";
|
||
if (hasHigh && value > (referenceHigh as number)) return "above";
|
||
return "in-range";
|
||
}
|
||
|
||
/**
|
||
* Render the reference range as text. Owns only the STRUCTURE — which of the
|
||
* four shapes (`low–high`, `≤ high`, `≥ low`, empty) applies — and defers
|
||
* every digit to the caller-supplied `formatNumber`, so each surface keeps
|
||
* its own number formatting byte-for-byte.
|
||
*
|
||
* `emptyText` is what to return when the lab reported no bounds at all; it
|
||
* defaults to the empty string (the lab list never renders the no-bounds
|
||
* case), while the PDF passes a neutral em-dash placeholder.
|
||
*/
|
||
export function formatReferenceRange(
|
||
low: number | null | undefined,
|
||
high: number | null | undefined,
|
||
formatNumber: (value: number) => string,
|
||
opts?: { emptyText?: string },
|
||
): string {
|
||
const hasLow = low !== null && low !== undefined;
|
||
const hasHigh = high !== null && high !== undefined;
|
||
if (hasLow && hasHigh) {
|
||
return `${formatNumber(low as number)}–${formatNumber(high as number)}`;
|
||
}
|
||
if (hasHigh) return `≤ ${formatNumber(high as number)}`;
|
||
if (hasLow) return `≥ ${formatNumber(low as number)}`;
|
||
return opts?.emptyText ?? "";
|
||
}
|
||
|
||
/**
|
||
* The reference range a lab report printed beside ONE reading, as stored on
|
||
* `LabResult.sourceReference*`.
|
||
*/
|
||
export interface SourceReferenceRange {
|
||
sourceReferenceLow: number | null;
|
||
sourceReferenceHigh: number | null;
|
||
sourceReferenceText: string | null;
|
||
}
|
||
|
||
/** Where the bounds a reading is judged against came from. */
|
||
export type ReferenceRangeOrigin = "source" | "catalog" | "none";
|
||
|
||
/** The bounds one reading is judged against, plus how they were arrived at. */
|
||
export interface EffectiveReferenceRange {
|
||
/** The bound the verdict uses. */
|
||
low: number | null;
|
||
high: number | null;
|
||
origin: ReferenceRangeOrigin;
|
||
/** 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. */
|
||
sourceText: string | 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
|
||
* same number reads differently against the two, so a surface that shows
|
||
* only one of them is showing a partial answer.
|
||
*/
|
||
divergesFromCatalog: boolean;
|
||
}
|
||
|
||
/** Two bounds are the same window when both ends match (null included). */
|
||
function sameWindow(
|
||
aLow: number | null,
|
||
aHigh: number | null,
|
||
bLow: number | null,
|
||
bHigh: number | null,
|
||
): boolean {
|
||
return aLow === bLow && aHigh === bHigh;
|
||
}
|
||
|
||
/**
|
||
* Resolve the reference window ONE reading is judged against.
|
||
*
|
||
* A lab runs its own method on its own device, and the physician reads the
|
||
* value against the window printed on that report. So for a reading that
|
||
* carries a source window, the source window wins — for that reading alone.
|
||
* The catalog band stays the net for every reading that carries none.
|
||
*
|
||
* A source window counts as stating a window only when it yields at least one
|
||
* numeric bound. A printed string with no derivable bound ("negativ", "siehe
|
||
* Befund") is carried through as `sourceText` but never displaces the catalog:
|
||
* a range that could not be read is not an argument for having no range.
|
||
*
|
||
* This function is the ONLY place that precedence is expressed. Every surface
|
||
* that classifies a reading — the API DTO, the doctor report, insights, the
|
||
* coach snapshot, the MCP read, the FHIR export — routes through it, so a
|
||
* reading cannot read as in-range on one surface and out on another.
|
||
*/
|
||
export function resolveEffectiveReferenceRange(
|
||
catalogLow: number | null | undefined,
|
||
catalogHigh: number | null | undefined,
|
||
source: SourceReferenceRange | null | undefined,
|
||
): EffectiveReferenceRange {
|
||
const cLow = catalogLow ?? null;
|
||
const cHigh = catalogHigh ?? null;
|
||
const sLow = source?.sourceReferenceLow ?? null;
|
||
const sHigh = source?.sourceReferenceHigh ?? null;
|
||
const sourceText = source?.sourceReferenceText ?? null;
|
||
|
||
if (sLow !== null || sHigh !== null) {
|
||
return {
|
||
low: sLow,
|
||
high: sHigh,
|
||
origin: "source",
|
||
catalogLow: cLow,
|
||
catalogHigh: cHigh,
|
||
sourceText,
|
||
divergesFromCatalog:
|
||
(cLow !== null || cHigh !== null) &&
|
||
!sameWindow(sLow, sHigh, cLow, cHigh),
|
||
};
|
||
}
|
||
|
||
return {
|
||
low: cLow,
|
||
high: cHigh,
|
||
origin: cLow !== null || cHigh !== null ? "catalog" : "none",
|
||
catalogLow: cLow,
|
||
catalogHigh: cHigh,
|
||
sourceText,
|
||
divergesFromCatalog: false,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Classify one reading against its effective window. Qualitative readings
|
||
* (no numeric value) always report `"unknown"` — there is nothing to compare
|
||
* against bounds, and a fabricated verdict is worse than none.
|
||
*/
|
||
export function classifyAgainstEffectiveRange(
|
||
value: number | null,
|
||
effective: EffectiveReferenceRange,
|
||
): ReferenceRangeStatus {
|
||
if (value === null) return "unknown";
|
||
return classifyReferenceRange(value, effective.low, effective.high);
|
||
}
|