refactor(labs): one shared reference-range classifier and range formatter

Reference-range classification + low/high range rendering was implemented three
times (validations, PDF core, lab list). Extract a single classifier and a
structure-only range formatter that takes a per-surface number formatter, so
each surface keeps its own number formatting while sharing the canonical
classification. Behaviour is unchanged per surface and now covered by one test.
This commit is contained in:
Marc-André Bombeck committed 2026-06-16 00:58:27 +02:00
1 parent ba7a72f484
commit bf5deb33e2
5 files changed
+158 -40

No files matched your search

+6 -6
View File
@@ -6,6 +6,7 @@ import { FlaskConical } from "lucide-react";
import { toast } from "sonner";
import { DeleteButton } from "@/components/data-list";
import { formatReferenceRange } from "@/lib/labs/reference-range";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -186,12 +187,11 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
group.latest.referenceHigh !== null ? (
<span className="text-xs">
{t("labs.referenceLabel")}{" "}
{group.latest.referenceLow !== null &&
group.latest.referenceHigh !== null
? `${formatValue(group.latest.referenceLow)}–${formatValue(group.latest.referenceHigh)}`
: group.latest.referenceHigh !== null
? `≤ ${formatValue(group.latest.referenceHigh)}`
: `≥ ${formatValue(group.latest.referenceLow as number)}`}
{formatReferenceRange(
group.latest.referenceLow,
group.latest.referenceHigh,
formatValue,
)}
</span>
) : null}
<span className="text-xs">
+16 -13
View File
@@ -14,6 +14,10 @@ import autoTable from "jspdf-autotable";
import { makeFormatters } from "./format-locale";
import type { Locale } from "./i18n/config";
import { convertGlucose, resolveGlucoseUnit } from "./glucose";
import {
classifyReferenceRange,
formatReferenceRange,
} from "./labs/reference-range";
import type { DoctorReportData } from "./doctor-report-data";
type T = (key: string, params?: Record<string, string | number>) => string;
@@ -1338,15 +1342,8 @@ export function buildDoctorReportPdfDocument(
doc.text(t("doctorReport.labsTitle"), margin, y);
y += 6;
const rangeText = (
low: number | null,
high: number | null,
): string => {
if (low !== null && high !== null) return `${num(low)}–${num(high)}`;
if (high !== null) return `≤ ${num(high)}`;
if (low !== null) return `≥ ${num(low)}`;
return "—";
};
const rangeText = (low: number | null, high: number | null): string =>
formatReferenceRange(low, high, num, { emptyText: "—" });
// Quiet, non-colour status glyph. In-range reads as a neutral dash so
// the table is not a field of warning marks; out-of-range reads as a
// direction arrow the clinician can scan, with no alarm tint.
@@ -1355,10 +1352,16 @@ export function buildDoctorReportPdfDocument(
low: number | null,
high: number | null,
): string => {
if (low === null && high === null) return "";
if (low !== null && value < low) return "↓";
if (high !== null && value > high) return "↑";
return "—";
switch (classifyReferenceRange(value, low, high)) {
case "unknown":
return "";
case "below":
return "↓";
case "above":
return "↑";
default:
return "—";
}
};
const labRows = data.labResults.map((lr) => [
@@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";
import {
classifyReferenceRange,
formatReferenceRange,
} from "@/lib/labs/reference-range";
describe("classifyReferenceRange", () => {
it("is unknown when no bounds are reported", () => {
expect(classifyReferenceRange(5, null, null)).toBe("unknown");
expect(classifyReferenceRange(5, undefined, undefined)).toBe("unknown");
});
it("treats both bounds as inclusive", () => {
expect(classifyReferenceRange(4, 4, 6)).toBe("in-range");
expect(classifyReferenceRange(6, 4, 6)).toBe("in-range");
expect(classifyReferenceRange(5, 4, 6)).toBe("in-range");
});
it("flags below / above the full range", () => {
expect(classifyReferenceRange(3.9, 4, 6)).toBe("below");
expect(classifyReferenceRange(6.1, 4, 6)).toBe("above");
});
it("classifies against a high-only bound", () => {
expect(classifyReferenceRange(100, null, 116)).toBe("in-range");
expect(classifyReferenceRange(120, null, 116)).toBe("above");
});
it("classifies against a low-only bound", () => {
expect(classifyReferenceRange(8, 10, null)).toBe("below");
expect(classifyReferenceRange(12, 10, null)).toBe("in-range");
});
});
describe("formatReferenceRange", () => {
// An identity-ish formatter keeps the structure assertions readable.
const fmt = (n: number) => String(n);
it("renders a full low–high range with an en-dash and no spaces", () => {
expect(formatReferenceRange(4, 6, fmt)).toBe("4–6");
});
it("renders a high-only bound with ≤", () => {
expect(formatReferenceRange(null, 116, fmt)).toBe("≤ 116");
});
it("renders a low-only bound with ≥", () => {
expect(formatReferenceRange(10, null, fmt)).toBe("≥ 10");
});
it("returns the empty string for no bounds by default", () => {
expect(formatReferenceRange(null, null, fmt)).toBe("");
expect(formatReferenceRange(undefined, undefined, fmt)).toBe("");
});
it("honours a custom emptyText for the no-bounds case", () => {
expect(formatReferenceRange(null, null, fmt, { emptyText: "—" })).toBe("—");
});
it("defers digit formatting entirely to the callback", () => {
const oneDecimal = (n: number) => n.toFixed(1);
expect(formatReferenceRange(4, 6, oneDecimal)).toBe("4.0–6.0");
});
});
+63
View File
@@ -0,0 +1,63 @@
/**
* 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 ?? "";
}
+8 -21
View File
@@ -145,25 +145,12 @@ export const listLabResultsSchema = z.object({
export type ListLabResultsInput = z.infer<typeof listLabResultsSchema>;
/**
* 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.
* Reference-range classification lives in `@/lib/labs/reference-range` so the
* API response, the doctor-report PDF, and the lab list card all share one
* implementation. Re-exported here for the existing import sites.
*/
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";
}
export {
classifyReferenceRange,
formatReferenceRange,
type ReferenceRangeStatus,
} from "@/lib/labs/reference-range";