fix(vorsorge): add a loading state and align the cards on the shared primitives

The reminder list read isLoading but rendered nothing, so the page
showed a header over blank space while fetching.

- Loading now paints a card-shaped Skeleton stack via the shared
  primitive.
- The no-data case routes through the shared EmptyState with an icon
  and an add action, matching the lab empty.
- Reminder cards title themselves with CardHeader/CardTitle + CardAction
  and the form card drops its pt-6 override, so the card token owns the
  vertical rhythm and the double-pad goes away. The card stays neutral —
  due state still reads only through the discreet badge.
This commit is contained in:
Marc-André Bombeck committed 2026-06-15 01:22:57 +02:00
1 parent bc237d61e2
commit dba107de51
2 files changed
+129 -26

No files matched your search

@@ -0,0 +1,74 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import type { MeasurementReminder } from "@/hooks/use-measurement-reminders";
/**
* v1.17.1 — the Vorsorge section's loading + empty affordances.
*
* Audit 02-H1/H2 + 11-H1: the list read `isLoading` but rendered nothing while
* fetching (a header over blank space). The fix paints a tile-shaped `Skeleton`
* stack while loading and routes the no-data case through the shared
* `<EmptyState>` with an add action. These tests pin both.
*/
const remindersMock = vi.fn();
vi.mock("@/hooks/use-measurement-reminders", () => ({
useMeasurementReminders: () => remindersMock(),
useMeasurementReminderMutations: () => ({
create: { mutate: vi.fn(), isPending: false },
remove: { mutate: vi.fn(), isPending: false },
satisfy: { mutate: vi.fn(), isPending: false },
}),
}));
import { VorsorgeSection } from "../vorsorge-section";
function render(node: React.ReactNode) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
beforeEach(() => {
remindersMock.mockReset();
});
describe("<VorsorgeSection> loading + empty", () => {
it("paints the shared Skeleton stack while loading", () => {
remindersMock.mockReturnValue({ data: undefined, isLoading: true });
const html = render(<VorsorgeSection />);
expect(html).toContain('data-slot="vorsorge-loading"');
expect(html).toContain('data-slot="skeleton"');
// No empty-state while still loading.
expect(html).not.toContain('data-slot="empty-state"');
});
it("routes the no-data case through the shared EmptyState with an action", () => {
remindersMock.mockReturnValue({ data: [], isLoading: false });
const html = render(<VorsorgeSection />);
expect(html).toContain('data-slot="empty-state"');
expect(html).toContain("border-dashed");
expect(html).not.toContain('data-slot="vorsorge-loading"');
});
it("renders reminder cards once data lands", () => {
const reminder: MeasurementReminder = {
id: "r1",
label: "Annual blood panel",
measurementType: null,
intervalDays: 365,
rrule: null,
nextDueAt: null,
notifyHour: 9,
location: null,
enabled: true,
} as MeasurementReminder;
remindersMock.mockReturnValue({ data: [reminder], isLoading: false });
const html = render(<VorsorgeSection />);
expect(html).toContain("Annual blood panel");
expect(html).not.toContain('data-slot="empty-state"');
expect(html).not.toContain('data-slot="vorsorge-loading"');
});
});
@@ -18,11 +18,18 @@ import { CheckCircle2, Plus, Trash2 } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { Badge } from "@/components/ui/badge";
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 { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { Skeleton } from "@/components/ui/skeleton";
import {
useMeasurementReminders,
useMeasurementReminderMutations,
@@ -137,7 +144,7 @@ export function VorsorgeSection({ enabled = true }: { enabled?: boolean }) {
{showForm && (
<Card>
<CardContent className="space-y-4 pt-6">
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="vorsorge-label">
{t("measurementReminders.form.label")}
@@ -263,10 +270,32 @@ export function VorsorgeSection({ enabled = true }: { enabled?: boolean }) {
</Card>
)}
{isLoading && !showForm && (
<div className="space-y-3" data-slot="vorsorge-loading">
{Array.from({ length: 3 }, (_, i) => (
<Card key={i} aria-hidden="true">
<CardContent className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-28" />
</div>
<Skeleton className="h-8 w-24 shrink-0" />
</CardContent>
</Card>
))}
</div>
)}
{!isLoading && (reminders?.length ?? 0) === 0 && !showForm && (
<EmptyState
icon={<Plus className="size-6" />}
title={t("measurementReminders.empty.title")}
description={t("measurementReminders.empty.description")}
action={
<Button type="button" onClick={() => setShowForm(true)}>
{t("measurementReminders.addButton")}
</Button>
}
/>
)}
@@ -315,31 +344,16 @@ function VorsorgeCard({
{/* NEUTRAL card — no status-driven colour. The due state reads only
through the discreet badge below. */}
<Card>
<CardContent className="flex items-start justify-between gap-3 pt-6">
<div className="min-w-0 space-y-1">
<div className="flex items-center gap-2">
<p className="truncate font-medium">{reminder.label}</p>
{!reminder.enabled && (
<Badge variant="outline">
{t("measurementReminders.disabledBadge")}
</Badge>
)}
</div>
<p className="text-muted-foreground text-sm">
<Badge variant="secondary" className="mr-2">
{t(`measurementReminders.${due.key}`, { days: due.days })}
<CardHeader>
<CardTitle className="flex min-w-0 items-center gap-2 text-sm">
<span className="truncate font-medium">{reminder.label}</span>
{!reminder.enabled && (
<Badge variant="outline">
{t("measurementReminders.disabledBadge")}
</Badge>
{cadence}
</p>
{reminder.location && (
<p className="text-muted-foreground text-sm">
{t("measurementReminders.location.prefix", {
location: reminder.location,
})}
</p>
)}
</div>
<div className="flex shrink-0 gap-1">
</CardTitle>
<CardAction className="flex gap-1">
<Button
type="button"
size="sm"
@@ -361,7 +375,22 @@ function VorsorgeCard({
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardAction>
</CardHeader>
<CardContent className="space-y-1">
<p className="text-muted-foreground text-sm">
<Badge variant="secondary" className="mr-2">
{t(`measurementReminders.${due.key}`, { days: due.days })}
</Badge>
{cadence}
</p>
{reminder.location && (
<p className="text-muted-foreground text-sm">
{t("measurementReminders.location.prefix", {
location: reminder.location,
})}
</p>
)}
</CardContent>
</Card>
</li>