rework of range bar ind lab list

This commit is contained in:
Timon_bed committed 2026-08-30 17:24:46 +02:00
1 parent 320788a984
commit de9a16b69d
6 files changed
+260 -46

No files matched your search

@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { LabReferenceRangeBar } from "../lab-reference-range-bar";
function render(
props: Partial<React.ComponentProps<typeof LabReferenceRangeBar>> = {},
) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<LabReferenceRangeBar
value={72}
referenceLow={60}
referenceHigh={100}
unit="bpm"
{...props}
/>
</I18nProvider>,
);
}
describe("<LabReferenceRangeBar>", () => {
it("renders the existing colored range bar for a complete numeric range", () => {
const html = render();
expect(html).toContain('data-slot="lab-reference-range-bar"');
expect(html).toContain('data-slot="target-range-bar"');
expect(html).toContain("bg-info/35");
expect(html).toContain("bg-warning/35");
expect(html).toContain("bg-success/35");
expect(html).toContain('left:0%;width:');
});
it.each([
["a qualitative value", { value: null }],
["a missing reference range", { referenceLow: null, referenceHigh: null }],
["an inverted range", { referenceLow: 100, referenceHigh: 60 }],
])("omits the bar for %s", (_label, props) => {
expect(render(props)).not.toContain('data-slot="lab-reference-range-bar"');
});
it("renders one-sided upper and lower reference limits", () => {
expect(render({ referenceLow: null })).toContain('aria-label="≤100 bpm"');
expect(render({ referenceHigh: null })).toContain('aria-label="≥60 bpm"');
});
});
+10 -1
View File
@@ -35,6 +35,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { ReferenceRangeBadge } from "./reference-range-badge";
import { LabReferenceRangeBar } from "./lab-reference-range-bar";
import { SourceRangeNote } from "./source-range-note";
import type { LabResultDetailDto, LabResultDto } from "./types";
@@ -351,7 +352,15 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
{ordered.map((r) => (
<TableRow key={r.id}>
<TableCell className="font-semibold tabular-nums">
{formatLabReading(r)}
<div className="space-y-2">
<span>{formatLabReading(r)}</span>
<LabReferenceRangeBar
value={r.value}
referenceLow={r.referenceLow}
referenceHigh={r.referenceHigh}
unit={r.unit}
/>
</div>
</TableCell>
<TableCell className="text-muted-foreground text-sm whitespace-nowrap">
{formatDateShort(r.takenAt, true)}
+36 -10
View File
@@ -22,6 +22,7 @@ import { queryKeys } from "@/lib/query-keys";
import { applyOrder, useModuleListPrefs } from "@/lib/module-list-prefs";
import { LabTrendSparkline } from "./lab-trend-sparkline";
import { LabReferenceRangeBar } from "./lab-reference-range-bar";
import { ReferenceRangeBadge } from "./reference-range-badge";
import { SourceRangeNote } from "./source-range-note";
import type { LabResultDto, LabResultListResponse } from "./types";
@@ -77,6 +78,19 @@ function groupReadings(results: LabResultDto[]): MarkerGroup[] {
);
}
function LabRangeBarSlot({ reading }: { reading: LabResultDto }) {
return (
<div className="w-48">
<LabReferenceRangeBar
value={reading.value}
referenceLow={reading.referenceLow}
referenceHigh={reading.referenceHigh}
unit={reading.unit}
/>
</div>
);
}
export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
const { t } = useTranslations();
const { canAdd } = useRecordCapabilities();
@@ -227,23 +241,29 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
{group.biomarkerId ? (
<Link
href={`/labs/${group.biomarkerId}`}
className="hover:bg-muted/40 -m-1 flex min-w-0 flex-1 items-center gap-3 rounded-md p-1 transition-colors"
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}
<LabTrendSparkline
values={group.readings.map((r) => r.value)}
/>
<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="flex min-w-0 flex-1 items-center gap-3">
<div className="grid min-w-0 flex-1 grid-cols-[minmax(0,1fr)_12rem_72px_auto] items-center gap-3">
{inner}
<LabTrendSparkline
values={group.readings.map((r) => r.value)}
/>
<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>
@@ -300,8 +320,8 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
linkLabel={group.analyte}
/>
<CardContent>
<div className="flex items-center justify-between gap-3">
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 text-sm">
<div className="flex items-end justify-between gap-3">
<div className="text-muted-foreground flex min-w-0 flex-1 flex-wrap items-center gap-x-2 text-sm">
<span className="text-foreground font-semibold tabular-nums">
{formatLabReading(group.latest)}
</span>
@@ -331,6 +351,12 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
})}
</span>
) : null}
<LabReferenceRangeBar
value={group.latest.value}
referenceLow={group.latest.referenceLow}
referenceHigh={group.latest.referenceHigh}
unit={group.latest.unit}
/>
</div>
<LabTrendSparkline
values={group.readings.map((r) => r.value)}
@@ -0,0 +1,63 @@
"use client";
import { RangeBar } from "@/components/targets/range-bar";
/**
* v1.37.34 — compact reference-range visualization for saved lab readings.
*
* The adapter keeps lab-specific data rules out of the shared target bar:
* qualitative readings are omitted, one-sided limits get a bounded visual
* scale, and invalid ranges remain text-only.
*/
export function LabReferenceRangeBar({
value,
referenceLow,
referenceHigh,
unit,
}: {
value: number | null;
referenceLow: number | null;
referenceHigh: number | null;
unit: string;
}) {
const hasLow = referenceLow !== null && Number.isFinite(referenceLow);
const hasHigh = referenceHigh !== null && Number.isFinite(referenceHigh);
if (
value === null ||
!Number.isFinite(value) ||
(!hasLow && !hasHigh) ||
(hasLow && hasHigh && referenceLow! >= referenceHigh!)
) {
return null;
}
// A missing endpoint gets a synthetic opposite edge only for positioning;
// the visible label still shows the original one-sided limit.
const scale = Math.max(Math.abs(referenceLow ?? referenceHigh!), 1);
const min = referenceLow ?? referenceHigh! - scale;
const max = referenceHigh ?? referenceLow! + scale;
const rangeLabel = !hasLow
? `≤${referenceHigh} ${unit}`
: !hasHigh
? `≥${referenceLow} ${unit}`
: `${referenceLow}–${referenceHigh} ${unit}`;
return (
<div
data-slot="lab-reference-range-bar"
className="w-full max-w-48 shrink-0"
aria-label={rangeLabel}
>
<RangeBar
value={value}
min={min}
max={max}
unit={unit}
compact
tone="lab"
minLabel={referenceLow === null ? null : undefined}
maxLabel={referenceHigh === null ? null : undefined}
/>
</div>
);
}
+10 -3
View File
@@ -54,13 +54,20 @@ export function ReferenceRangeBadge({
);
}
// Below / above — both neutral `secondary`, distinguished only by the
// arrow direction and label. No red, no amber.
// Below / above use a restrained semantic tint, distinguished by the
// arrow direction and label rather than an alarm treatment.
const Icon = status === "below" ? ArrowDown : ArrowUp;
const label =
status === "below" ? t("labs.range.below") : t("labs.range.above");
const toneClass =
status === "below"
? "border-info/30 bg-info/10 text-info"
: "border-warning/30 bg-warning/10 text-warning";
return (
<Badge variant="secondary" className={compactClass || undefined}>
<Badge
variant="outline"
className={`${toneClass} ${compactClass}`.trim()}
>
<Icon aria-hidden />
{label}
</Badge>