From bc237d61e23d7820fb3851504689f1e81030721b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 15 Jun 2026 01:22:50 +0200 Subject: [PATCH] fix(labs): converge the lab tile + loading onto the shared primitives The lab list shipped a second bespoke compact-tile shape and a centred Loader2 that disagreed in size with the page spinner above it. - Lab cards now title themselves with CardHeader/CardTitle and pin the sparkline + delete control in CardAction, matching the canonical card contract; the py-4 content override is dropped so the card token drives spacing. - Loading paints a tile-shaped Skeleton stack via the shared primitive instead of the bespoke spinner, removing the size drift and the layout pop-in. --- .../labs/__tests__/lab-list-loading.test.tsx | 44 +++++++ src/components/labs/lab-list.tsx | 107 ++++++++++-------- 2 files changed, 105 insertions(+), 46 deletions(-) create mode 100644 src/components/labs/__tests__/lab-list-loading.test.tsx diff --git a/src/components/labs/__tests__/lab-list-loading.test.tsx b/src/components/labs/__tests__/lab-list-loading.test.tsx new file mode 100644 index 000000000..d01a62c43 --- /dev/null +++ b/src/components/labs/__tests__/lab-list-loading.test.tsx @@ -0,0 +1,44 @@ +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"; + +/** + * v1.17.1 — the lab list's loading affordance. + * + * Audit 02-H2/L2: the list used a bespoke centred `` (a different + * spinner size than the page above it). The fix paints a tile-shaped + * `Skeleton` stack via the shared primitive so labs / recovery / sleep-quality + * share one loading dialect. On first synchronous render the query is pending, + * so the skeleton branch shows. + */ + +vi.mock("@/lib/api/api-fetch", () => ({ + // Never resolves within the synchronous SSR render → query stays loading. + apiGet: () => new Promise(() => {}), + apiDelete: vi.fn(), +})); + +import { LabList } from "../lab-list"; + +function render(node: React.ReactNode) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return renderToStaticMarkup( + + {node} + , + ); +} + +describe(" loading", () => { + it("paints the shared Skeleton stack while loading", () => { + const html = render(); + expect(html).toContain('data-slot="lab-list-loading"'); + expect(html).toContain('data-slot="skeleton"'); + // No bespoke spinner anymore. + expect(html).not.toContain("animate-spin"); + }); +}); diff --git a/src/components/labs/lab-list.tsx b/src/components/labs/lab-list.tsx index 0b67f2e71..ab1a0cd5c 100644 --- a/src/components/labs/lab-list.tsx +++ b/src/components/labs/lab-list.tsx @@ -2,12 +2,19 @@ import { useMemo } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { FlaskConical, Loader2, Trash2 } from "lucide-react"; +import { FlaskConical, Trash2 } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; -import { Card, CardContent } from "@/components/ui/card"; +import { + Card, + CardAction, + CardContent, + CardHeader, + CardTitle, +} from "@/components/ui/card"; import { EmptyState } from "@/components/ui/empty-state"; +import { Skeleton } from "@/components/ui/skeleton"; import { apiDelete, apiGet } from "@/lib/api/api-fetch"; import { formatDate } from "@/lib/format"; import { useTranslations } from "@/lib/i18n/context"; @@ -104,8 +111,18 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) { if (isLoading) { return ( -
- +
+ {Array.from({ length: 3 }, (_, i) => ( + + ))}
); } @@ -137,49 +154,18 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
{groups.map((group) => ( - -
-
- {group.analyte} - {group.panel ? ( - - {group.panel} - - ) : null} - -
-
- - {formatValue(group.latest.value)} {group.latest.unit} + + + {group.analyte} + {group.panel ? ( + + {group.panel} - {group.latest.referenceLow !== null || - group.latest.referenceHigh !== null ? ( - - {t("labs.referenceLabel")}{" "} - {group.latest.referenceLow !== null && - group.latest.referenceHigh !== null - ? `${formatValue(group.latest.referenceLow)}–${formatValue(group.latest.referenceHigh)}` - : group.latest.referenceHigh !== null - ? `≤ ${formatValue(group.latest.referenceHigh)}` - : `≥ ${formatValue(group.latest.referenceLow as number)}`} - - ) : null} - - {formatDate(group.latest.takenAt)} - - {group.readings.length > 1 ? ( - - {t("labs.readingsCount", { - count: group.readings.length, - })} - - ) : null} -
-
-
- r.value)} - /> + ) : null} + + + + r.value)} /> + + + +
+ + {formatValue(group.latest.value)} {group.latest.unit} + + {group.latest.referenceLow !== null || + group.latest.referenceHigh !== null ? ( + + {t("labs.referenceLabel")}{" "} + {group.latest.referenceLow !== null && + group.latest.referenceHigh !== null + ? `${formatValue(group.latest.referenceLow)}–${formatValue(group.latest.referenceHigh)}` + : group.latest.referenceHigh !== null + ? `≤ ${formatValue(group.latest.referenceHigh)}` + : `≥ ${formatValue(group.latest.referenceLow as number)}`} + + ) : null} + + {formatDate(group.latest.takenAt)} + + {group.readings.length > 1 ? ( + + {t("labs.readingsCount", { + count: group.readings.length, + })} + + ) : null}