mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +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>
|
||||
|
||||
Reference in new issue
Block a user