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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-15 01:22:50 +02:00
1 parent dad0b89a8c
commit bc237d61e2
2 files changed
+105 -46

No files matched your search

@@ -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 `<Loader2>` (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(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">{node}</I18nProvider>
</QueryClientProvider>,
);
}
describe("<LabList> loading", () => {
it("paints the shared Skeleton stack while loading", () => {
const html = render(<LabList />);
expect(html).toContain('data-slot="lab-list-loading"');
expect(html).toContain('data-slot="skeleton"');
// No bespoke spinner anymore.
expect(html).not.toContain("animate-spin");
});
});
+61 -46
View File
@@ -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 (
<div className="flex h-48 items-center justify-center">
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
<div className="space-y-3" data-slot="lab-list-loading">
{Array.from({ length: 3 }, (_, i) => (
<Card key={i} aria-hidden="true">
<CardContent className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 space-y-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-56" />
</div>
<Skeleton className="h-8 w-24 self-end sm:self-center" />
</CardContent>
</Card>
))}
</div>
);
}
@@ -137,49 +154,18 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
<div className="space-y-3">
{groups.map((group) => (
<Card key={group.analyte.toLowerCase()}>
<CardContent className="flex flex-col gap-3 py-4 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
<span className="font-medium">{group.analyte}</span>
{group.panel ? (
<span className="text-muted-foreground text-xs">
{group.panel}
</span>
) : null}
<ReferenceRangeBadge status={group.latest.rangeStatus} />
</div>
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 text-sm">
<span className="text-foreground font-semibold tabular-nums">
{formatValue(group.latest.value)} {group.latest.unit}
<CardHeader>
<CardTitle className="flex flex-wrap items-baseline gap-x-2 gap-y-1 text-sm">
<span className="font-medium">{group.analyte}</span>
{group.panel ? (
<span className="text-muted-foreground text-xs font-normal">
{group.panel}
</span>
{group.latest.referenceLow !== null ||
group.latest.referenceHigh !== null ? (
<span className="text-xs">
{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)}`}
</span>
) : null}
<span className="text-xs">
{formatDate(group.latest.takenAt)}
</span>
{group.readings.length > 1 ? (
<span className="text-xs">
{t("labs.readingsCount", {
count: group.readings.length,
})}
</span>
) : null}
</div>
</div>
<div className="flex items-center gap-3 self-end sm:self-center">
<LabTrendSparkline
values={group.readings.map((r) => r.value)}
/>
) : null}
<ReferenceRangeBadge status={group.latest.rangeStatus} />
</CardTitle>
<CardAction className="flex items-center gap-3">
<LabTrendSparkline values={group.readings.map((r) => r.value)} />
<Button
variant="ghost"
size="icon"
@@ -190,6 +176,35 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
>
<Trash2 className="h-4 w-4" />
</Button>
</CardAction>
</CardHeader>
<CardContent>
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 text-sm">
<span className="text-foreground font-semibold tabular-nums">
{formatValue(group.latest.value)} {group.latest.unit}
</span>
{group.latest.referenceLow !== null ||
group.latest.referenceHigh !== null ? (
<span className="text-xs">
{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)}`}
</span>
) : null}
<span className="text-xs">
{formatDate(group.latest.takenAt)}
</span>
{group.readings.length > 1 ? (
<span className="text-xs">
{t("labs.readingsCount", {
count: group.readings.length,
})}
</span>
) : null}
</div>
</CardContent>
</Card>