diff --git a/src/components/labs/lab-list.tsx b/src/components/labs/lab-list.tsx index 3d94b330e..bf24b1201 100644 --- a/src/components/labs/lab-list.tsx +++ b/src/components/labs/lab-list.tsx @@ -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 ? ( {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, + )} ) : null} diff --git a/src/lib/doctor-report-pdf-core.ts b/src/lib/doctor-report-pdf-core.ts index 36ea49480..11fd211b5 100644 --- a/src/lib/doctor-report-pdf-core.ts +++ b/src/lib/doctor-report-pdf-core.ts @@ -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; @@ -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) => [ diff --git a/src/lib/labs/__tests__/reference-range.test.ts b/src/lib/labs/__tests__/reference-range.test.ts new file mode 100644 index 000000000..fe56d3733 --- /dev/null +++ b/src/lib/labs/__tests__/reference-range.test.ts @@ -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"); + }); +}); diff --git a/src/lib/labs/reference-range.ts b/src/lib/labs/reference-range.ts new file mode 100644 index 000000000..85369b176 --- /dev/null +++ b/src/lib/labs/reference-range.ts @@ -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 ?? ""; +} diff --git a/src/lib/validations/labs.ts b/src/lib/validations/labs.ts index fde18bc7c..3a28ee7b2 100644 --- a/src/lib/validations/labs.ts +++ b/src/lib/validations/labs.ts @@ -145,25 +145,12 @@ export const listLabResultsSchema = z.object({ export type ListLabResultsInput = z.infer; /** - * 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";