From de9a16b69d4b2f034d275b8b047fec6ba8a62d6f Mon Sep 17 00:00:00 2001 From: Timon_bed Date: Sun, 30 Aug 2026 17:24:46 +0200 Subject: [PATCH 1/3] rework of range bar ind lab list --- .../lab-reference-range-bar.test.tsx | 48 +++++++ src/components/labs/lab-history-list.tsx | 11 +- src/components/labs/lab-list.tsx | 46 +++++-- .../labs/lab-reference-range-bar.tsx | 63 +++++++++ src/components/labs/reference-range-badge.tsx | 13 +- src/components/targets/range-bar.tsx | 125 +++++++++++++----- 6 files changed, 260 insertions(+), 46 deletions(-) create mode 100644 src/components/labs/__tests__/lab-reference-range-bar.test.tsx create mode 100644 src/components/labs/lab-reference-range-bar.tsx diff --git a/src/components/labs/__tests__/lab-reference-range-bar.test.tsx b/src/components/labs/__tests__/lab-reference-range-bar.test.tsx new file mode 100644 index 000000000..6b6219e84 --- /dev/null +++ b/src/components/labs/__tests__/lab-reference-range-bar.test.tsx @@ -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> = {}, +) { + return renderToStaticMarkup( + + + , + ); +} + +describe("", () => { + 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"'); + }); +}); diff --git a/src/components/labs/lab-history-list.tsx b/src/components/labs/lab-history-list.tsx index 16f7052ba..8dd025644 100644 --- a/src/components/labs/lab-history-list.tsx +++ b/src/components/labs/lab-history-list.tsx @@ -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) => ( - {formatLabReading(r)} +
+ {formatLabReading(r)} + +
{formatDateShort(r.takenAt, true)} diff --git a/src/components/labs/lab-list.tsx b/src/components/labs/lab-list.tsx index c3207f4e9..57b21e729 100644 --- a/src/components/labs/lab-list.tsx +++ b/src/components/labs/lab-list.tsx @@ -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 ( +
+ +
+ ); +} + export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) { const { t } = useTranslations(); const { canAdd } = useRecordCapabilities(); @@ -227,23 +241,29 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) { {group.biomarkerId ? ( {inner} - r.value)} - /> + +
+ 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. -
+
{inner} - r.value)} - /> + +
+ r.value)} + /> +
{t("labs.notLinkedYet")} @@ -300,8 +320,8 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) { linkLabel={group.analyte} /> -
-
+
+
{formatLabReading(group.latest)} @@ -331,6 +351,12 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) { })} ) : null} +
r.value)} diff --git a/src/components/labs/lab-reference-range-bar.tsx b/src/components/labs/lab-reference-range-bar.tsx new file mode 100644 index 000000000..cdfe6ff9c --- /dev/null +++ b/src/components/labs/lab-reference-range-bar.tsx @@ -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 ( +
+ +
+ ); +} diff --git a/src/components/labs/reference-range-badge.tsx b/src/components/labs/reference-range-badge.tsx index 26b68c625..428637d80 100644 --- a/src/components/labs/reference-range-badge.tsx +++ b/src/components/labs/reference-range-badge.tsx @@ -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 ( - + {label} diff --git a/src/components/targets/range-bar.tsx b/src/components/targets/range-bar.tsx index e1b8a9c78..a6923ade5 100644 --- a/src/components/targets/range-bar.tsx +++ b/src/components/targets/range-bar.tsx @@ -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,28 @@ 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; + 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 outlierPadding = Math.max(span * 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 +102,18 @@ 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 +138,53 @@ export function RangeBar({ ].join(". "); return ( -
-
- {/* 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. */} -
- {/* Orange (caution) side zones — `--dracula-orange` matches the - marker's out-of-band tint. */} +
+
+ {/* Full-track tint stays muted for lab ranges and alarm-coloured for + target ranges; both use semantic theme tokens. */}
+ {/* Side zones use the lab's calm information palette or the target + range's caution palette. */} +
- {/* In-band green zone — `--dracula-green`. */} + {/* In-band zone — `--success` remains the shared positive signal. */}
-
+
- {min} {unit} + {minLabel === null ? null : (minLabel ?? `${min} ${unit}`)} - {max} {unit} + {maxLabel === null ? null : (maxLabel ?? `${max} ${unit}`)}
From d1251634b364f195e735d3212584d1937232f8ae Mon Sep 17 00:00:00 2001 From: Timon_bed Date: Sun, 30 Aug 2026 18:07:19 +0200 Subject: [PATCH 2/3] prettier fix --- .../lab-reference-range-bar.test.tsx | 2 +- src/components/labs/reference-range-badge.tsx | 5 +- src/components/targets/range-bar.tsx | 51 +++++++++---------- 3 files changed, 25 insertions(+), 33 deletions(-) diff --git a/src/components/labs/__tests__/lab-reference-range-bar.test.tsx b/src/components/labs/__tests__/lab-reference-range-bar.test.tsx index 6b6219e84..b1af61c78 100644 --- a/src/components/labs/__tests__/lab-reference-range-bar.test.tsx +++ b/src/components/labs/__tests__/lab-reference-range-bar.test.tsx @@ -30,7 +30,7 @@ describe("", () => { expect(html).toContain("bg-info/35"); expect(html).toContain("bg-warning/35"); expect(html).toContain("bg-success/35"); - expect(html).toContain('left:0%;width:'); + expect(html).toContain("left:0%;width:"); }); it.each([ diff --git a/src/components/labs/reference-range-badge.tsx b/src/components/labs/reference-range-badge.tsx index 428637d80..d48cad65a 100644 --- a/src/components/labs/reference-range-badge.tsx +++ b/src/components/labs/reference-range-badge.tsx @@ -64,10 +64,7 @@ export function ReferenceRangeBadge({ ? "border-info/30 bg-info/10 text-info" : "border-warning/30 bg-warning/10 text-warning"; return ( - + {label} diff --git a/src/components/targets/range-bar.tsx b/src/components/targets/range-bar.tsx index a6923ade5..d7b776a5b 100644 --- a/src/components/targets/range-bar.tsx +++ b/src/components/targets/range-bar.tsx @@ -68,14 +68,12 @@ export function RangeBar({ // 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 outlierPadding = Math.max(span * 0.06, Number.EPSILON); - const visualMin = - isLab - ? Math.min(baseVisualMin, value - outlierPadding) - : baseVisualMin; - const visualMax = - isLab - ? Math.max(baseVisualMax, value + outlierPadding) - : baseVisualMax; + 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; @@ -102,18 +100,17 @@ export function RangeBar({ const inYellow = !inGreen && value >= effectiveOrangeMin && value <= effectiveOrangeMax; - const markerColor = - isLab - ? inGreen - ? "var(--success)" - : value < min - ? "var(--info)" - : "var(--warning)" - : 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)); @@ -162,10 +159,9 @@ export function RangeBar({ }`} style={{ left: isLab ? "0%" : `${yellowLeftStart}%`, - width: - isLab - ? `${greenStart}%` - : `${greenStart - yellowLeftStart}%`, + width: isLab + ? `${greenStart}%` + : `${greenStart - yellowLeftStart}%`, }} />
{/* In-band zone — `--success` remains the shared positive signal. */} From f30a202a675061997b7aaac29466f7f69525d97f Mon Sep 17 00:00:00 2001 From: Timon_bed Date: Sun, 30 Aug 2026 23:45:47 +0200 Subject: [PATCH 3/3] fix: keep lab outliers clear of range bar edges --- src/components/targets/__tests__/range-bar.test.tsx | 13 +++++++++++++ src/components/targets/range-bar.tsx | 8 +++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/components/targets/__tests__/range-bar.test.tsx b/src/components/targets/__tests__/range-bar.test.tsx index bb045a3a4..2f4f2df3e 100644 --- a/src/components/targets/__tests__/range-bar.test.tsx +++ b/src/components/targets/__tests__/range-bar.test.tsx @@ -58,6 +58,19 @@ describe("", () => { 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, diff --git a/src/components/targets/range-bar.tsx b/src/components/targets/range-bar.tsx index d7b776a5b..a4a992117 100644 --- a/src/components/targets/range-bar.tsx +++ b/src/components/targets/range-bar.tsx @@ -54,6 +54,8 @@ export function RangeBar({ const effectiveOrangeMax = orangeMax != null ? Math.max(orangeMax, max) : computedOrangeMax; + // 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, @@ -67,7 +69,11 @@ export function RangeBar({ // 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 outlierPadding = Math.max(span * 0.06, Number.EPSILON); + 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;