Merge pull request #860 from TimonBed/feat/labs-ocr-openai-fix

rework of range bar ind lab list
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-08-31 00:04:14 +02:00
commit 7cca0a0710
7 files changed
+271 -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>
);
}
@@ -54,13 +54,17 @@ 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>
@@ -58,6 +58,19 @@ describe("<RangeBar>", () => {
expect(html).toContain("var(--destructive)");
});
it("keeps a lab outlier clear of the bar edge", () => {
const html = render({
value: 6.8,
min: 30,
max: 100,
unit: "ng/mL",
compact: true,
tone: "lab",
});
expect(html).not.toContain("left: 4%");
});
it("makes the marker focusable with a text alternative for the value + range (2026-07-17 a11y audit M1)", () => {
const html = render({
value: 72,
+94 -32
View File
@@ -24,6 +24,10 @@ export interface RangeBarProps {
unit: string;
orangeMin?: number;
orangeMax?: number;
compact?: boolean;
tone?: "target" | "lab";
minLabel?: string | null;
maxLabel?: string | null;
}
export function RangeBar({
@@ -33,11 +37,16 @@ export function RangeBar({
unit,
orangeMin,
orangeMax,
compact = false,
tone = "target",
minLabel,
maxLabel,
}: RangeBarProps) {
const { t } = useTranslations();
const isLab = tone === "lab";
const span = max - min;
const defaultOrangeWidth = span * 0.3;
const defaultOrangeWidth = span * (isLab ? 0.12 : 0.3);
const computedOrangeMin = min - defaultOrangeWidth;
const computedOrangeMax = max + defaultOrangeWidth;
const effectiveOrangeMin =
@@ -45,10 +54,32 @@ export function RangeBar({
const effectiveOrangeMax =
orangeMax != null ? Math.max(orangeMax, max) : computedOrangeMax;
const orangeSpan = Math.max(1, effectiveOrangeMax - effectiveOrangeMin);
const sidePadding = Math.max(1, orangeSpan * 0.18);
const visualMin = effectiveOrangeMin - sidePadding;
const visualMax = effectiveOrangeMax + sidePadding;
// Keep zero-width ranges from producing invalid percentages. The lab
// adapter currently avoids this by adding a synthetic bound when needed.
const orangeSpan = Math.max(
isLab ? Number.EPSILON : 1,
effectiveOrangeMax - effectiveOrangeMin,
);
const sidePadding = Math.max(
isLab ? Number.EPSILON : 1,
orangeSpan * (isLab ? 0.06 : 0.18),
);
const baseVisualMin = effectiveOrangeMin - sidePadding;
const baseVisualMax = effectiveOrangeMax + sidePadding;
// Lab values can sit far outside a narrow reference window (for example
// vitamin D at 6.8 against a minimum of 30). Expand the visible scale to
// include the actual value instead of pinning its marker to the edge.
const baseVisualSpan = Math.max(
baseVisualMax - baseVisualMin,
Number.EPSILON,
);
const outlierPadding = Math.max(baseVisualSpan * 0.06, Number.EPSILON);
const visualMin = isLab
? Math.min(baseVisualMin, value - outlierPadding)
: baseVisualMin;
const visualMax = isLab
? Math.max(baseVisualMax, value + outlierPadding)
: baseVisualMax;
const visualSpan = visualMax - visualMin;
const clampedValue = Math.max(visualMin, Math.min(visualMax, value));
const rawPosition = ((clampedValue - visualMin) / visualSpan) * 100;
@@ -75,11 +106,17 @@ export function RangeBar({
const inYellow =
!inGreen && value >= effectiveOrangeMin && value <= effectiveOrangeMax;
const markerColor = inGreen
? "var(--success)"
: inYellow
? "var(--warning)"
: "var(--destructive)";
const markerColor = isLab
? inGreen
? "var(--success)"
: value < min
? "var(--info)"
: "var(--warning)"
: inGreen
? "var(--success)"
: inYellow
? "var(--warning)"
: "var(--destructive)";
const minLabelPosition = Math.max(5, Math.min(95, greenStart));
const maxLabelPosition = Math.max(5, Math.min(95, greenEnd));
@@ -104,32 +141,51 @@ export function RangeBar({
].join(". ");
return (
<div className="space-y-1.5" data-slot="target-range-bar">
<div className="bg-muted/50 relative h-3 w-full overflow-hidden rounded-full">
{/* Red background (full bar) — Dracula `--dracula-red` so the
chart palette stays aligned with PR-badge, alerts, and the
marker dot itself. Raw Tailwind palettes drift from the
theme over time and never get dark-mode tuned. */}
<div className="bg-destructive/10 absolute inset-0 rounded-full" />
{/* Orange (caution) side zones — `--dracula-orange` matches the
marker's out-of-band tint. */}
<div
className={compact ? "space-y-0.5" : "space-y-1.5"}
data-slot="target-range-bar"
>
<div
className={`bg-muted/50 relative w-full overflow-hidden rounded-full ${
compact ? "h-2" : "h-3"
}`}
>
{/* Full-track tint stays muted for lab ranges and alarm-coloured for
target ranges; both use semantic theme tokens. */}
<div
className="bg-warning/15 absolute top-0 h-full"
className={`absolute inset-0 rounded-full ${
isLab ? "bg-muted/70" : "bg-destructive/10"
}`}
/>
{/* Side zones use the lab's calm information palette or the target
range's caution palette. */}
<div
className={`absolute top-0 h-full ${
isLab ? "bg-info/35" : "bg-warning/15"
}`}
style={{
left: `${yellowLeftStart}%`,
width: `${greenStart - yellowLeftStart}%`,
left: isLab ? "0%" : `${yellowLeftStart}%`,
width: isLab
? `${greenStart}%`
: `${greenStart - yellowLeftStart}%`,
}}
/>
<div
className="bg-warning/15 absolute top-0 h-full"
className={`absolute top-0 h-full ${
isLab ? "bg-warning/35" : "bg-warning/15"
}`}
style={{
left: `${greenEnd}%`,
width: `${yellowRightEnd - greenEnd}%`,
width: isLab
? `${100 - greenEnd}%`
: `${yellowRightEnd - greenEnd}%`,
}}
/>
{/* In-band green zone — `--dracula-green`. */}
{/* In-band zone — `--success` remains the shared positive signal. */}
<div
className="bg-success/20 absolute top-0 h-full"
className={`absolute top-0 h-full ${
isLab ? "bg-success/35" : "bg-success/20"
}`}
style={{
left: `${greenStart}%`,
width: `${greenEnd - greenStart}%`,
@@ -143,7 +199,9 @@ export function RangeBar({
role="img"
tabIndex={0}
aria-label={markerAriaLabel}
className="focus-visible:ring-ring absolute top-1/2 h-5 w-5 -translate-x-1/2 -translate-y-1/2 cursor-pointer rounded-full border-2 shadow-sm focus-visible:ring-2 focus-visible:outline-none"
className={`focus-visible:ring-ring absolute top-1/2 -translate-x-1/2 -translate-y-1/2 cursor-pointer rounded-full shadow-sm focus-visible:ring-2 focus-visible:outline-none ${
compact ? "h-5 w-1 border" : "h-5 w-5 border-2"
}`}
style={{
left: `${position}%`,
backgroundColor: markerColor,
@@ -167,18 +225,22 @@ export function RangeBar({
</Tooltip>
</TooltipProvider>
</div>
<div className="text-muted-foreground relative h-4 text-xs">
<div
className={`text-muted-foreground relative ${
compact ? "h-3 text-[10px]" : "h-4 text-xs"
}`}
>
<span
className="absolute -translate-x-1/2"
className="absolute -translate-x-1/2 whitespace-nowrap"
style={{ left: `${minLabelPosition}%` }}
>
{min} {unit}
{minLabel === null ? null : (minLabel ?? `${min} ${unit}`)}
</span>
<span
className="absolute -translate-x-1/2"
className="absolute -translate-x-1/2 whitespace-nowrap"
style={{ left: `${maxLabelPosition}%` }}
>
{max} {unit}
{maxLabel === null ? null : (maxLabel ?? `${max} ${unit}`)}
</span>
</div>
</div>