Improve lab range and trend visual alignment

This commit is contained in:
Timon_bed committed 2026-08-31 11:56:54 +02:00
1 parent 4308114c5b
commit 68a3b57597
3 files changed
+101 -55

No files matched your search

+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
+48 -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,62 @@ 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";
}
if (referenceLow !== null) {
return value < referenceLow ? "fill-info" : "fill-success";
}
if (referenceHigh !== 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>