mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
rework of range bar ind lab list
This commit is contained in:
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"');
|
||||
});
|
||||
});
|
||||
@@ -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)}
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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 (
|
||||
<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 +198,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 +224,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>
|
||||
|
||||
Reference in new issue
Block a user