mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
Improve lab range and trend visual alignment
This commit is contained in:
1 parent
4308114c5b
commit
68a3b57597
3 files changed
+101
-55
No files matched your search
@@ -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,62 @@ 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";
|
||||
}
|
||||
if (referenceLow !== null) {
|
||||
return value < referenceLow ? "fill-info" : "fill-success";
|
||||
}
|
||||
if (referenceHigh !== 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