mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
Merge pull request #880 from TimonBed/lab-visual-improvement
Improve lab range and trend visual alignment
This commit is contained in:
4 files changed
+189
-55
No files matched your search
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Which colour each reading earns, for every shape a reference window can
|
||||
* arrive in.
|
||||
*
|
||||
* The case worth spelling out is the inverted pair. A floor at or above its
|
||||
* ceiling is not a one-sided window, it is a window that cannot be true, and
|
||||
* the tempting reading — "well, we still have a minimum" — paints a high value
|
||||
* green. That is the wrong direction for a transcription error to fail in, so
|
||||
* it earns no verdict at all, matching what `lab-reference-range-bar.tsx` does
|
||||
* with the same input.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { LabTrendSparkline } from "../lab-trend-sparkline";
|
||||
|
||||
const READINGS = [10, 50, 150];
|
||||
const MUTED = [
|
||||
"fill-muted-foreground",
|
||||
"fill-muted-foreground",
|
||||
"fill-muted-foreground",
|
||||
];
|
||||
|
||||
/** The fill class of each plotted reading, oldest → newest. */
|
||||
function fills(
|
||||
referenceLow: number | null,
|
||||
referenceHigh: number | null,
|
||||
): string[] {
|
||||
const markup = renderToStaticMarkup(
|
||||
<LabTrendSparkline
|
||||
values={READINGS}
|
||||
referenceLow={referenceLow}
|
||||
referenceHigh={referenceHigh}
|
||||
/>,
|
||||
);
|
||||
// Each reading is a small rect, not a circle — this PR squares them off to
|
||||
// match the reference-range bar's marker.
|
||||
return Array.from(markup.matchAll(/<rect[^>]*class="([^"]*)"/gu)).map(
|
||||
(m) => m[1],
|
||||
);
|
||||
}
|
||||
|
||||
describe("a reading's colour against its reference window", () => {
|
||||
it("judges against a real band", () => {
|
||||
expect(fills(30, 100)).toEqual([
|
||||
"fill-info",
|
||||
"fill-success",
|
||||
"fill-warning",
|
||||
]);
|
||||
});
|
||||
|
||||
it("judges a floor-only window", () => {
|
||||
expect(fills(30, null)).toEqual([
|
||||
"fill-info",
|
||||
"fill-success",
|
||||
"fill-success",
|
||||
]);
|
||||
});
|
||||
|
||||
it("judges a ceiling-only window", () => {
|
||||
expect(fills(null, 100)).toEqual([
|
||||
"fill-success",
|
||||
"fill-success",
|
||||
"fill-warning",
|
||||
]);
|
||||
});
|
||||
|
||||
it("gives no verdict without a window", () => {
|
||||
expect(fills(null, null)).toEqual(MUTED);
|
||||
});
|
||||
|
||||
it("gives no verdict on an inverted pair rather than reading it as a floor", () => {
|
||||
// 100/30 — the same numbers as the valid band, transposed. Read as
|
||||
// "minimum 100" this would paint 150 green and 50 flagged.
|
||||
expect(fills(100, 30)).toEqual(MUTED);
|
||||
});
|
||||
|
||||
it("gives no verdict when floor and ceiling are equal", () => {
|
||||
// A zero-width band cannot separate "inside" from "outside" either.
|
||||
expect(fills(50, 50)).toEqual(MUTED);
|
||||
});
|
||||
});
|
||||
@@ -211,7 +211,7 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
|
||||
</p>
|
||||
) : null}
|
||||
<Card>
|
||||
<CardContent className="divide-border divide-y p-0">
|
||||
<CardContent className="divide-border grid grid-cols-[minmax(0,1fr)_max-content_12rem_72px_auto] gap-x-3 divide-y p-0">
|
||||
{groups.map((group) => {
|
||||
const inner = (
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -219,10 +219,6 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
|
||||
<span className="truncate font-medium">
|
||||
{group.analyte}
|
||||
</span>
|
||||
<ReferenceRangeBadge
|
||||
status={group.latest.rangeStatus}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 text-xs">
|
||||
<span className="text-foreground font-semibold tabular-nums">
|
||||
@@ -233,42 +229,53 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
|
||||
</div>
|
||||
);
|
||||
// The whole row navigates into the detail (where delete lives).
|
||||
return (
|
||||
return group.biomarkerId ? (
|
||||
<Link
|
||||
key={group.key}
|
||||
href={`/labs/${group.biomarkerId}`}
|
||||
className="hover:bg-muted/40 col-span-full grid min-w-0 grid-cols-subgrid items-center px-4 py-2.5 transition-colors"
|
||||
>
|
||||
{inner}
|
||||
<ReferenceRangeBadge
|
||||
status={group.latest.rangeStatus}
|
||||
compact
|
||||
className="w-full"
|
||||
/>
|
||||
<LabRangeBarSlot reading={group.latest} />
|
||||
<div className="w-[72px]">
|
||||
<LabTrendSparkline
|
||||
values={group.readings.map((reading) => reading.value)}
|
||||
referenceLow={group.latest.referenceLow}
|
||||
referenceHigh={group.latest.referenceHigh}
|
||||
/>
|
||||
</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
|
||||
key={group.key}
|
||||
className="flex items-center justify-between gap-2 px-4 py-2.5"
|
||||
className="col-span-full grid min-w-0 grid-cols-subgrid items-center px-4 py-2.5"
|
||||
>
|
||||
{group.biomarkerId ? (
|
||||
<Link
|
||||
href={`/labs/${group.biomarkerId}`}
|
||||
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}
|
||||
<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="grid min-w-0 flex-1 grid-cols-[minmax(0,1fr)_12rem_72px_auto] items-center gap-3">
|
||||
{inner}
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
{inner}
|
||||
<ReferenceRangeBadge
|
||||
status={group.latest.rangeStatus}
|
||||
compact
|
||||
className="w-full"
|
||||
/>
|
||||
<LabRangeBarSlot reading={group.latest} />
|
||||
<div className="w-[72px]">
|
||||
<LabTrendSparkline
|
||||
values={group.readings.map((reading) => reading.value)}
|
||||
referenceLow={group.latest.referenceLow}
|
||||
referenceHigh={group.latest.referenceHigh}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-muted-foreground shrink-0 text-xs italic">
|
||||
{t("labs.notLinkedYet")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -359,7 +366,9 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
|
||||
/>
|
||||
</div>
|
||||
<LabTrendSparkline
|
||||
values={group.readings.map((r) => r.value)}
|
||||
values={group.readings.map((reading) => reading.value)}
|
||||
referenceLow={group.latest.referenceLow}
|
||||
referenceHigh={group.latest.referenceHigh}
|
||||
/>
|
||||
</div>
|
||||
{/* Un-linked group: the header renders no link, so name the
|
||||
|
||||
@@ -13,9 +13,9 @@
|
||||
* polyline vs Recharts) is used by the doctor-report PDF. The two are not
|
||||
* drift; do not unify them.
|
||||
*
|
||||
* Rendered only when an analyte has ≥ 2 NUMERIC readings. Stroke uses the
|
||||
* neutral `currentColor` so it inherits the calm muted-foreground tone of its
|
||||
* row — no status colour, in keeping with the no-alarming-colour ethos.
|
||||
* Rendered only when an analyte has ≥ 2 NUMERIC readings. The calm neutral
|
||||
* line preserves the compact graph, while each reading uses the adjacent
|
||||
* reference-range bar's semantic colour.
|
||||
*
|
||||
* `values` are passed oldest → newest. v1.18.9 — qualitative readings carry no
|
||||
* number (`null`); they are filtered out here so a series with qualitative
|
||||
@@ -23,10 +23,14 @@
|
||||
*/
|
||||
export function LabTrendSparkline({
|
||||
values: rawValues,
|
||||
referenceLow = null,
|
||||
referenceHigh = null,
|
||||
width = 72,
|
||||
height = 20,
|
||||
}: {
|
||||
values: (number | null)[];
|
||||
referenceLow?: number | null;
|
||||
referenceHigh?: number | null;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}) {
|
||||
@@ -40,32 +44,68 @@ export function LabTrendSparkline({
|
||||
const pad = 2;
|
||||
const usableH = height - pad * 2;
|
||||
|
||||
const points = values
|
||||
.map((v, i) => {
|
||||
const x = i * stepX;
|
||||
// Invert Y so a higher value sits higher on screen.
|
||||
const y = pad + usableH - ((v - min) / span) * usableH;
|
||||
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
})
|
||||
const points = values.map((v, i) => {
|
||||
const x = i * stepX;
|
||||
// Invert Y so a higher value sits higher on screen.
|
||||
const y = pad + usableH - ((v - min) / span) * usableH;
|
||||
return { value: v, x, y };
|
||||
});
|
||||
const polylinePoints = points
|
||||
.map(({ x, y }) => `${x.toFixed(1)},${y.toFixed(1)}`)
|
||||
.join(" ");
|
||||
const isValidRange =
|
||||
referenceLow !== null &&
|
||||
referenceHigh !== null &&
|
||||
referenceLow < referenceHigh;
|
||||
const pointClassName = (value: number) => {
|
||||
if (isValidRange) {
|
||||
if (value < referenceLow) return "fill-info";
|
||||
if (value > referenceHigh) return "fill-warning";
|
||||
return "fill-success";
|
||||
}
|
||||
// Each one-sided branch requires the OTHER bound to be absent. Without
|
||||
// that, a present-but-impossible pair (floor at or above ceiling) reads as
|
||||
// "minimum only" and paints a high value green — the wrong direction for a
|
||||
// transcription error to fail in. Falling through to the neutral dot
|
||||
// matches `lab-reference-range-bar.tsx`, which renders nothing at all for
|
||||
// the same input: a range that cannot be true earns no verdict.
|
||||
if (referenceLow !== null && referenceHigh === null) {
|
||||
return value < referenceLow ? "fill-info" : "fill-success";
|
||||
}
|
||||
if (referenceHigh !== null && referenceLow === null) {
|
||||
return value > referenceHigh ? "fill-warning" : "fill-success";
|
||||
}
|
||||
return "fill-muted-foreground";
|
||||
};
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
className="text-muted-foreground overflow-visible"
|
||||
className="overflow-visible"
|
||||
aria-hidden
|
||||
role="presentation"
|
||||
>
|
||||
<polyline
|
||||
points={points}
|
||||
points={polylinePoints}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.25}
|
||||
className="stroke-muted-foreground"
|
||||
strokeWidth={2}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
{points.map(({ value, x, y }, index) => (
|
||||
<rect
|
||||
key={index}
|
||||
x={x - 2}
|
||||
y={y - 4}
|
||||
width={4}
|
||||
height={8}
|
||||
rx={1}
|
||||
className={pointClassName(value)}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { ArrowDown, ArrowUp, Minus } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ReferenceRangeStatus } from "@/lib/validations/labs";
|
||||
|
||||
/**
|
||||
@@ -23,6 +24,7 @@ import type { ReferenceRangeStatus } from "@/lib/validations/labs";
|
||||
export function ReferenceRangeBadge({
|
||||
status,
|
||||
compact = false,
|
||||
className,
|
||||
}: {
|
||||
status: ReferenceRangeStatus;
|
||||
/**
|
||||
@@ -31,6 +33,7 @@ export function ReferenceRangeBadge({
|
||||
* The hero/detail view keeps the default (un-compact) size.
|
||||
*/
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
@@ -46,7 +49,7 @@ export function ReferenceRangeBadge({
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-muted-foreground ${compactClass}`.trim()}
|
||||
className={cn("text-muted-foreground", compactClass, className)}
|
||||
>
|
||||
{compact ? null : <Minus aria-hidden />}
|
||||
{t("labs.range.inRange")}
|
||||
@@ -64,7 +67,7 @@ export function ReferenceRangeBadge({
|
||||
? "border-info/30 bg-info/10 text-info"
|
||||
: "border-warning/30 bg-warning/10 text-warning";
|
||||
return (
|
||||
<Badge variant="outline" className={`${toneClass} ${compactClass}`.trim()}>
|
||||
<Badge variant="outline" className={cn(toneClass, compactClass, className)}>
|
||||
<Icon aria-hidden />
|
||||
{label}
|
||||
</Badge>
|
||||
|
||||
Reference in new issue
Block a user