mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user