fix(labs): keep the range bar and trend in their columns when a reading has no range

The range badge renders nothing for a reading without a reference range, so
that row had four cells instead of five and the range bar and the trend
moved one column to the left. The badge now sits in a cell of its own. The
chevron is hidden from lg up, where the row's hover already says it opens;
a phone keeps it.
This commit is contained in:
Marc-André Bombeck committed 2026-10-03 18:38:31 +02:00
1 parent d805ccf28b
commit 5c300a8d9c
2 files changed
+127 -11

No files matched your search

@@ -0,0 +1,109 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { LabResultDto, LabResultListResponse } from "../types";
/**
* Every row of the compact list is five grid cells from `lg` up. The range
* badge renders nothing for a reading without a reference range, so without
* its own cell the range bar and the trend slid one column to the left on
* exactly those rows.
*/
vi.mock("@/lib/api/api-fetch", () => ({
apiGet: () => new Promise(() => {}),
apiDelete: vi.fn(),
}));
import { LabList } from "../lab-list";
function reading(over: Partial<LabResultDto>): LabResultDto {
return {
id: "r1",
biomarkerId: "b1",
panel: null,
analyte: "Ferritin",
value: 80,
valueText: null,
unit: "ng/mL",
referenceLow: 30,
referenceHigh: 400,
catalogReferenceLow: 30,
catalogReferenceHigh: 400,
sourceReferenceLow: null,
sourceReferenceHigh: null,
sourceReferenceText: null,
referenceOrigin: "catalog",
referenceDivergesFromCatalog: false,
takenAt: "2026-09-01T12:00:00.000Z",
source: "MANUAL",
hasNote: false,
rangeStatus: "in_range",
createdAt: "2026-09-01T12:00:00.000Z",
updatedAt: "2026-09-01T12:00:00.000Z",
...over,
} as LabResultDto;
}
function renderList(results: LabResultDto[]) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
});
const data: LabResultListResponse = {
results,
meta: { total: results.length, limit: 500, offset: 0 },
};
queryClient.setQueryData(
queryKeys.labResultsList({
biomarkerId: undefined,
analyte: undefined,
panel: undefined,
from: undefined,
to: undefined,
page: 0,
sortDir: "desc",
}),
data,
);
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
<LabList />
</I18nProvider>
</QueryClientProvider>,
);
}
function count(html: string, needle: string): number {
return html.split(needle).length - 1;
}
describe("<LabList> columns", () => {
it("gives the badge its own cell even when a reading has no reference range", () => {
const html = renderList([
reading({ id: "a", biomarkerId: "b1", analyte: "Ferritin" }),
reading({
id: "b",
biomarkerId: "b2",
analyte: "Lipoprotein (a)",
referenceLow: null,
referenceHigh: null,
catalogReferenceLow: null,
catalogReferenceHigh: null,
rangeStatus: "unknown",
}),
]);
expect(count(html, 'href="/labs/')).toBe(2);
// One cell per row, rendered even where the badge itself renders nothing.
expect(count(html, 'data-slot="lab-list-badge-cell"')).toBe(2);
expect(html).toMatch(/<div data-slot="lab-list-badge-cell"[^>]*><\/div>/);
});
it("hides the chevron from lg up and keeps it on a phone", () => {
const html = renderList([reading({})]);
expect(html).toMatch(/class="[^"]*lucide-chevron-right[^"]*lg:invisible/);
});
});
+18 -11
View File
@@ -78,6 +78,19 @@ function groupReadings(results: LabResultDto[]): MarkerGroup[] {
);
}
/**
* The badge's grid cell. The badge renders nothing for a reading without a
* reference range, and a missing cell would pull the range bar and the trend
* one column to the left; the wrapper keeps every row at five cells.
*/
function RangeBadgeCell({ status }: { status: LabResultDto["rangeStatus"] }) {
return (
<div data-slot="lab-list-badge-cell" className={MOBILE_CELL.badge}>
<ReferenceRangeBadge status={status} compact className="lg:w-full" />
</div>
);
}
function LabRangeBarSlot({ reading }: { reading: LabResultDto }) {
return (
<div className={cn("w-full lg:w-48", MOBILE_CELL.rangeBar)}>
@@ -274,11 +287,7 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
)}
>
{inner}
<ReferenceRangeBadge
status={group.latest.rangeStatus}
compact
className={cn("lg:w-full", MOBILE_CELL.badge)}
/>
<RangeBadgeCell status={group.latest.rangeStatus} />
<LabRangeBarSlot reading={group.latest} />
<div className={cn("w-[72px]", MOBILE_CELL.trend)}>
<LabTrendSparkline
@@ -287,9 +296,11 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
referenceHigh={group.latest.referenceHigh}
/>
</div>
{/* With a pointer the row's hover already says it opens; a
phone has no hover, so the chevron stays there. */}
<ChevronRight
className={cn(
"text-muted-foreground h-4 w-4 shrink-0",
"text-muted-foreground h-4 w-4 shrink-0 lg:invisible",
MOBILE_CELL.end,
)}
/>
@@ -300,11 +311,7 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
// link next to its clickable neighbours.
<div key={group.key} className={ROW_CLASS}>
{inner}
<ReferenceRangeBadge
status={group.latest.rangeStatus}
compact
className={cn("lg:w-full", MOBILE_CELL.badge)}
/>
<RangeBadgeCell status={group.latest.rangeStatus} />
<LabRangeBarSlot reading={group.latest} />
<div className={cn("w-[72px]", MOBILE_CELL.trend)}>
<LabTrendSparkline