diff --git a/src/components/labs/__tests__/lab-trend-sparkline.test.tsx b/src/components/labs/__tests__/lab-trend-sparkline.test.tsx new file mode 100644 index 000000000..6b53ebb31 --- /dev/null +++ b/src/components/labs/__tests__/lab-trend-sparkline.test.tsx @@ -0,0 +1,82 @@ +/** + * Which colour each reading earns, for every shape a reference window can + * arrive in. + * + * The case worth spelling out is the inverted pair. A floor at or above its + * ceiling is not a one-sided window, it is a window that cannot be true, and + * the tempting reading — "well, we still have a minimum" — paints a high value + * green. That is the wrong direction for a transcription error to fail in, so + * it earns no verdict at all, matching what `lab-reference-range-bar.tsx` does + * with the same input. + */ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { LabTrendSparkline } from "../lab-trend-sparkline"; + +const READINGS = [10, 50, 150]; +const MUTED = [ + "fill-muted-foreground", + "fill-muted-foreground", + "fill-muted-foreground", +]; + +/** The fill class of each plotted reading, oldest → newest. */ +function fills( + referenceLow: number | null, + referenceHigh: number | null, +): string[] { + const markup = renderToStaticMarkup( + , + ); + // Each reading is a small rect, not a circle — this PR squares them off to + // match the reference-range bar's marker. + return Array.from(markup.matchAll(/]*class="([^"]*)"/gu)).map( + (m) => m[1], + ); +} + +describe("a reading's colour against its reference window", () => { + it("judges against a real band", () => { + expect(fills(30, 100)).toEqual([ + "fill-info", + "fill-success", + "fill-warning", + ]); + }); + + it("judges a floor-only window", () => { + expect(fills(30, null)).toEqual([ + "fill-info", + "fill-success", + "fill-success", + ]); + }); + + it("judges a ceiling-only window", () => { + expect(fills(null, 100)).toEqual([ + "fill-success", + "fill-success", + "fill-warning", + ]); + }); + + it("gives no verdict without a window", () => { + expect(fills(null, null)).toEqual(MUTED); + }); + + it("gives no verdict on an inverted pair rather than reading it as a floor", () => { + // 100/30 — the same numbers as the valid band, transposed. Read as + // "minimum 100" this would paint 150 green and 50 flagged. + expect(fills(100, 30)).toEqual(MUTED); + }); + + it("gives no verdict when floor and ceiling are equal", () => { + // A zero-width band cannot separate "inside" from "outside" either. + expect(fills(50, 50)).toEqual(MUTED); + }); +}); diff --git a/src/components/labs/lab-list.tsx b/src/components/labs/lab-list.tsx index 57b21e729..2f077bee4 100644 --- a/src/components/labs/lab-list.tsx +++ b/src/components/labs/lab-list.tsx @@ -211,7 +211,7 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {

) : null} - + {groups.map((group) => { const inner = (
@@ -219,10 +219,6 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) { {group.analyte} -
@@ -233,42 +229,53 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
); // The whole row navigates into the detail (where delete lives). - return ( + return group.biomarkerId ? ( + + {inner} + + +
+ reading.value)} + referenceLow={group.latest.referenceLow} + referenceHigh={group.latest.referenceHigh} + /> +
+ + + ) : ( + // Un-linked group (not backfilled to a catalog biomarker): + // render inert but say so, so it doesn't read as a broken + // link next to its clickable neighbours.
- {group.biomarkerId ? ( - - {inner} - -
- r.value)} - /> -
- - - ) : ( - // Un-linked group (not backfilled to a catalog biomarker): - // render inert but say so, so it doesn't read as a broken - // link next to its clickable neighbours. -
- {inner} - -
- r.value)} - /> -
- - {t("labs.notLinkedYet")} - -
- )} + {inner} + + +
+ reading.value)} + referenceLow={group.latest.referenceLow} + referenceHigh={group.latest.referenceHigh} + /> +
+ + {t("labs.notLinkedYet")} +
); })} @@ -359,7 +366,9 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) { /> r.value)} + values={group.readings.map((reading) => reading.value)} + referenceLow={group.latest.referenceLow} + referenceHigh={group.latest.referenceHigh} /> {/* Un-linked group: the header renders no link, so name the diff --git a/src/components/labs/lab-trend-sparkline.tsx b/src/components/labs/lab-trend-sparkline.tsx index 4633dc400..e2e297a08 100644 --- a/src/components/labs/lab-trend-sparkline.tsx +++ b/src/components/labs/lab-trend-sparkline.tsx @@ -13,9 +13,9 @@ * polyline vs Recharts) is used by the doctor-report PDF. The two are not * drift; do not unify them. * - * Rendered only when an analyte has ≥ 2 NUMERIC readings. Stroke uses the - * neutral `currentColor` so it inherits the calm muted-foreground tone of its - * row — no status colour, in keeping with the no-alarming-colour ethos. + * Rendered only when an analyte has ≥ 2 NUMERIC readings. The calm neutral + * line preserves the compact graph, while each reading uses the adjacent + * reference-range bar's semantic colour. * * `values` are passed oldest → newest. v1.18.9 — qualitative readings carry no * number (`null`); they are filtered out here so a series with qualitative @@ -23,10 +23,14 @@ */ export function LabTrendSparkline({ values: rawValues, + referenceLow = null, + referenceHigh = null, width = 72, height = 20, }: { values: (number | null)[]; + referenceLow?: number | null; + referenceHigh?: number | null; width?: number; height?: number; }) { @@ -40,32 +44,68 @@ export function LabTrendSparkline({ const pad = 2; const usableH = height - pad * 2; - const points = values - .map((v, i) => { - const x = i * stepX; - // Invert Y so a higher value sits higher on screen. - const y = pad + usableH - ((v - min) / span) * usableH; - return `${x.toFixed(1)},${y.toFixed(1)}`; - }) + const points = values.map((v, i) => { + const x = i * stepX; + // Invert Y so a higher value sits higher on screen. + const y = pad + usableH - ((v - min) / span) * usableH; + return { value: v, x, y }; + }); + const polylinePoints = points + .map(({ x, y }) => `${x.toFixed(1)},${y.toFixed(1)}`) .join(" "); + const isValidRange = + referenceLow !== null && + referenceHigh !== null && + referenceLow < referenceHigh; + const pointClassName = (value: number) => { + if (isValidRange) { + if (value < referenceLow) return "fill-info"; + if (value > referenceHigh) return "fill-warning"; + return "fill-success"; + } + // Each one-sided branch requires the OTHER bound to be absent. Without + // that, a present-but-impossible pair (floor at or above ceiling) reads as + // "minimum only" and paints a high value green — the wrong direction for a + // transcription error to fail in. Falling through to the neutral dot + // matches `lab-reference-range-bar.tsx`, which renders nothing at all for + // the same input: a range that cannot be true earns no verdict. + if (referenceLow !== null && referenceHigh === null) { + return value < referenceLow ? "fill-info" : "fill-success"; + } + if (referenceHigh !== null && referenceLow === null) { + return value > referenceHigh ? "fill-warning" : "fill-success"; + } + return "fill-muted-foreground"; + }; return ( + {points.map(({ value, x, y }, index) => ( + + ))} ); } diff --git a/src/components/labs/reference-range-badge.tsx b/src/components/labs/reference-range-badge.tsx index d48cad65a..4e59c1a8a 100644 --- a/src/components/labs/reference-range-badge.tsx +++ b/src/components/labs/reference-range-badge.tsx @@ -4,6 +4,7 @@ import { ArrowDown, ArrowUp, Minus } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { useTranslations } from "@/lib/i18n/context"; +import { cn } from "@/lib/utils"; import type { ReferenceRangeStatus } from "@/lib/validations/labs"; /** @@ -23,6 +24,7 @@ import type { ReferenceRangeStatus } from "@/lib/validations/labs"; export function ReferenceRangeBadge({ status, compact = false, + className, }: { status: ReferenceRangeStatus; /** @@ -31,6 +33,7 @@ export function ReferenceRangeBadge({ * The hero/detail view keeps the default (un-compact) size. */ compact?: boolean; + className?: string; }) { const { t } = useTranslations(); @@ -46,7 +49,7 @@ export function ReferenceRangeBadge({ return ( {compact ? null : } {t("labs.range.inRange")} @@ -64,7 +67,7 @@ export function ReferenceRangeBadge({ ? "border-info/30 bg-info/10 text-info" : "border-warning/30 bg-warning/10 text-warning"; return ( - + {label}