Merge pull request #880 from TimonBed/lab-visual-improvement

Improve lab range and trend visual alignment
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-09-01 19:06:09 +02:00
commit 8016d375c6
4 files changed
+189 -55

No files matched your search

@@ -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(
<LabTrendSparkline
values={READINGS}
referenceLow={referenceLow}
referenceHigh={referenceHigh}
/>,
);
// 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(/<rect[^>]*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);
});
});
+48 -39
View File
@@ -211,7 +211,7 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
</p>
) : null}
<Card>
<CardContent className="divide-border divide-y p-0">
<CardContent className="divide-border grid grid-cols-[minmax(0,1fr)_max-content_12rem_72px_auto] gap-x-3 divide-y p-0">
{groups.map((group) => {
const inner = (
<div className="min-w-0 flex-1">
@@ -219,10 +219,6 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
<span className="truncate font-medium">
{group.analyte}
</span>
<ReferenceRangeBadge
status={group.latest.rangeStatus}
compact
/>
</div>
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 text-xs">
<span className="text-foreground font-semibold tabular-nums">
@@ -233,42 +229,53 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
</div>
);
// The whole row navigates into the detail (where delete lives).
return (
return group.biomarkerId ? (
<Link
key={group.key}
href={`/labs/${group.biomarkerId}`}
className="hover:bg-muted/40 col-span-full grid min-w-0 grid-cols-subgrid items-center px-4 py-2.5 transition-colors"
>
{inner}
<ReferenceRangeBadge
status={group.latest.rangeStatus}
compact
className="w-full"
/>
<LabRangeBarSlot reading={group.latest} />
<div className="w-[72px]">
<LabTrendSparkline
values={group.readings.map((reading) => reading.value)}
referenceLow={group.latest.referenceLow}
referenceHigh={group.latest.referenceHigh}
/>
</div>
<ChevronRight className="text-muted-foreground h-4 w-4 shrink-0" />
</Link>
) : (
// 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.
<div
key={group.key}
className="flex items-center justify-between gap-2 px-4 py-2.5"
className="col-span-full grid min-w-0 grid-cols-subgrid items-center px-4 py-2.5"
>
{group.biomarkerId ? (
<Link
href={`/labs/${group.biomarkerId}`}
className="hover:bg-muted/40 -m-1 grid min-w-0 flex-1 grid-cols-[minmax(0,1fr)_12rem_72px_auto] items-center gap-3 rounded-md p-1 transition-colors"
>
{inner}
<LabRangeBarSlot reading={group.latest} />
<div className="w-[72px]">
<LabTrendSparkline
values={group.readings.map((r) => r.value)}
/>
</div>
<ChevronRight className="text-muted-foreground h-4 w-4 shrink-0" />
</Link>
) : (
// 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.
<div className="grid min-w-0 flex-1 grid-cols-[minmax(0,1fr)_12rem_72px_auto] items-center gap-3">
{inner}
<LabRangeBarSlot reading={group.latest} />
<div className="w-[72px]">
<LabTrendSparkline
values={group.readings.map((r) => r.value)}
/>
</div>
<span className="text-muted-foreground shrink-0 text-xs italic">
{t("labs.notLinkedYet")}
</span>
</div>
)}
{inner}
<ReferenceRangeBadge
status={group.latest.rangeStatus}
compact
className="w-full"
/>
<LabRangeBarSlot reading={group.latest} />
<div className="w-[72px]">
<LabTrendSparkline
values={group.readings.map((reading) => reading.value)}
referenceLow={group.latest.referenceLow}
referenceHigh={group.latest.referenceHigh}
/>
</div>
<span className="text-muted-foreground shrink-0 text-xs italic">
{t("labs.notLinkedYet")}
</span>
</div>
);
})}
@@ -359,7 +366,9 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
/>
</div>
<LabTrendSparkline
values={group.readings.map((r) => r.value)}
values={group.readings.map((reading) => reading.value)}
referenceLow={group.latest.referenceLow}
referenceHigh={group.latest.referenceHigh}
/>
</div>
{/* Un-linked group: the header renders no link, so name the
+54 -14
View File
@@ -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 (
<svg
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
className="text-muted-foreground overflow-visible"
className="overflow-visible"
aria-hidden
role="presentation"
>
<polyline
points={points}
points={polylinePoints}
fill="none"
stroke="currentColor"
strokeWidth={1.25}
className="stroke-muted-foreground"
strokeWidth={2}
strokeLinejoin="round"
strokeLinecap="round"
/>
{points.map(({ value, x, y }, index) => (
<rect
key={index}
x={x - 2}
y={y - 4}
width={4}
height={8}
rx={1}
className={pointClassName(value)}
/>
))}
</svg>
);
}
@@ -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 (
<Badge
variant="outline"
className={`text-muted-foreground ${compactClass}`.trim()}
className={cn("text-muted-foreground", compactClass, className)}
>
{compact ? null : <Minus aria-hidden />}
{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 (
<Badge variant="outline" className={`${toneClass} ${compactClass}`.trim()}>
<Badge variant="outline" className={cn(toneClass, compactClass, className)}>
<Icon aria-hidden />
{label}
</Badge>