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 } = {}) {
+
+
+ ) : (
+ // 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.
-
);
})}
@@ -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 (
);
}
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}