From 725cace8651aa3e3275ee6d3de82f6a82c975c63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 8 May 2026 12:47:21 +0200 Subject: [PATCH] feat(admin): status-first card grid + aggregator endpoint (#133) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the implicit, fluid status of the legacy admin monolith with a 6-card grid at the top of /admin per ui-guidelines.md §3.4. Each card pairs a §2.6 colored dot with a text label so state never relies on color alone (WCAG 1.4.1). - New aggregator endpoint /api/admin/status-overview returns a single batched Promise.all (no N+1) covering Users, Integrations, Monitoring, Backups, Maintenance, Audit log — with severity precomputed server-side. - New + reusable component in src/components/admin/. Pure buildCards() function makes severity mapping unit-testable. - Tests: 14 new (8 aggregator severity/shape, 6 grid badge + a11y). - Wires the grid above the existing admin sections; per-section extraction will land in follow-ups. Co-authored-by: Marc-André Bombeck --- src/app/admin/page.tsx | 3 + .../status-overview/__tests__/route.test.ts | 167 ++++++++++++ src/app/api/admin/status-overview/route.ts | 217 +++++++++++++++ .../admin/__tests__/status-card-grid.test.tsx | 122 +++++++++ src/components/admin/status-card-grid.tsx | 258 ++++++++++++++++++ 5 files changed, 767 insertions(+) create mode 100644 src/app/api/admin/status-overview/__tests__/route.test.ts create mode 100644 src/app/api/admin/status-overview/route.ts create mode 100644 src/components/admin/__tests__/status-card-grid.test.tsx create mode 100644 src/components/admin/status-card-grid.tsx diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index e6b64bcc9..6f8a8a9d0 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -62,6 +62,7 @@ import { PasswordStrength } from "@/components/ui/password-strength"; import { formatDate, formatDateTime } from "@/lib/format"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { toast } from "sonner"; +import { StatusCardGrid } from "@/components/admin/status-card-grid"; function PasswordInput(props: React.ComponentProps) { const [visible, setVisible] = useState(false); @@ -162,6 +163,8 @@ export default function AdminPage() {

{t("admin.subtitle")}

+ +
diff --git a/src/app/api/admin/status-overview/__tests__/route.test.ts b/src/app/api/admin/status-overview/__tests__/route.test.ts new file mode 100644 index 000000000..059a88b18 --- /dev/null +++ b/src/app/api/admin/status-overview/__tests__/route.test.ts @@ -0,0 +1,167 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { count: vi.fn() }, + withingsConnection: { count: vi.fn() }, + notificationChannel: { count: vi.fn() }, + pushSubscription: { count: vi.fn() }, + appSettings: { findUnique: vi.fn() }, + auditLog: { findFirst: vi.fn(), count: vi.fn() }, + dataBackup: { findFirst: vi.fn(), findMany: vi.fn() }, + }, +})); + +vi.mock("@/lib/api-handler", async () => { + const actual = + await vi.importActual( + "@/lib/api-handler", + ); + return { + ...actual, + apiHandler: Promise>( + h: T, + ): T => h, + requireAdmin: vi.fn().mockResolvedValue(undefined), + }; +}); + +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), +})); + +vi.mock("@/lib/jobs/worker-status", () => ({ + getWorkerStatus: vi.fn(() => ({ + running: true, + startedAt: new Date(Date.now() - 3_600_000).toISOString(), + lastHeartbeat: null, + lastReminderCheck: null, + lastWithingsSync: null, + lastInsightsRun: null, + jobsProcessed: 0, + errors: 0, + })), +})); + +import { GET } from "../route"; +import { prisma } from "@/lib/db"; + +const mocks = prisma as unknown as { + user: { count: ReturnType }; + withingsConnection: { count: ReturnType }; + notificationChannel: { count: ReturnType }; + pushSubscription: { count: ReturnType }; + appSettings: { findUnique: ReturnType }; + auditLog: { + findFirst: ReturnType; + count: ReturnType; + }; + dataBackup: { + findFirst: ReturnType; + findMany: ReturnType; + }; +}; + +describe("GET /api/admin/status-overview", () => { + beforeEach(() => { + vi.clearAllMocks(); + // default returns + mocks.user.count.mockResolvedValue(0); + mocks.withingsConnection.count.mockResolvedValue(0); + mocks.notificationChannel.count.mockResolvedValue(0); + mocks.pushSubscription.count.mockResolvedValue(0); + mocks.appSettings.findUnique.mockResolvedValue(null); + mocks.auditLog.findFirst.mockResolvedValue(null); + mocks.auditLog.count.mockResolvedValue(0); + mocks.dataBackup.findFirst.mockResolvedValue(null); + mocks.dataBackup.findMany.mockResolvedValue([]); + }); + + it("returns the 6-section overview shape", async () => { + const res = await GET(); + const body = (await res.json()) as { data: Record }; + expect(body.data).toHaveProperty("users"); + expect(body.data).toHaveProperty("integrations"); + expect(body.data).toHaveProperty("monitoring"); + expect(body.data).toHaveProperty("backups"); + expect(body.data).toHaveProperty("maintenance"); + expect(body.data).toHaveProperty("auditLog"); + }); + + it("batches DB calls in a single Promise.all (no N+1)", async () => { + await GET(); + // 3 user.count + 1 withings + 1 ntfy notifications + 1 webPush + 3 + // moodLog/telegram done via user.count → expect user.count called 4 times + // (total, admins, newThisWeek, moodLog, telegram) + expect(mocks.user.count).toHaveBeenCalledTimes(5); + expect(mocks.withingsConnection.count).toHaveBeenCalledTimes(1); + expect(mocks.notificationChannel.count).toHaveBeenCalledTimes(1); + expect(mocks.pushSubscription.count).toHaveBeenCalledTimes(1); + // The route does not loop — every aggregate is a single query. + expect(mocks.dataBackup.findMany).toHaveBeenCalledTimes(1); + }); + + it("flags backups as alert when none exist", async () => { + mocks.dataBackup.findFirst.mockResolvedValue(null); + const res = await GET(); + const body = (await res.json()) as { + data: { backups: { severity: string } }; + }; + expect(body.data.backups.severity).toBe("alert"); + }); + + it("flags backups as good when fresh (<8d)", async () => { + mocks.dataBackup.findFirst.mockResolvedValue({ + createdAt: new Date(Date.now() - 24 * 60 * 60 * 1000), + }); + const res = await GET(); + const body = (await res.json()) as { + data: { backups: { severity: string } }; + }; + expect(body.data.backups.severity).toBe("good"); + }); + + it("flags monitoring as alert on a recent error (<24h)", async () => { + mocks.auditLog.findFirst.mockImplementation( + ({ where }: { where: { action: unknown } }) => { + const action = where.action as { startsWith?: string } | string; + if ( + typeof action === "object" && + action.startsWith === "system.error" + ) { + return Promise.resolve({ createdAt: new Date(Date.now() - 60_000) }); + } + return Promise.resolve(null); + }, + ); + mocks.appSettings.findUnique.mockResolvedValue({ + glitchtipEnabled: true, + umamiEnabled: true, + }); + const res = await GET(); + const body = (await res.json()) as { + data: { monitoring: { severity: string } }; + }; + expect(body.data.monitoring.severity).toBe("alert"); + }); + + it("flags maintenance as alert when worker is stopped", async () => { + const wsModule = await import("@/lib/jobs/worker-status"); + vi.mocked(wsModule.getWorkerStatus).mockReturnValue({ + running: false, + startedAt: null, + lastHeartbeat: null, + lastReminderCheck: null, + lastWithingsSync: null, + lastInsightsRun: null, + jobsProcessed: 0, + errors: 0, + }); + const res = await GET(); + const body = (await res.json()) as { + data: { maintenance: { severity: string; workerRunning: boolean } }; + }; + expect(body.data.maintenance.severity).toBe("alert"); + expect(body.data.maintenance.workerRunning).toBe(false); + }); +}); diff --git a/src/app/api/admin/status-overview/route.ts b/src/app/api/admin/status-overview/route.ts new file mode 100644 index 000000000..702cbddbe --- /dev/null +++ b/src/app/api/admin/status-overview/route.ts @@ -0,0 +1,217 @@ +import { prisma } from "@/lib/db"; +import { apiHandler, requireAdmin } from "@/lib/api-handler"; +import { apiSuccess } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { getWorkerStatus } from "@/lib/jobs/worker-status"; + +/** + * Severity tokens map onto §2.6 status taxonomy of `docs/ui-guidelines.md`. + * Returned as plain strings so the client renders dots + labels — not RAW + * colours — and never leans on color alone for state. + */ +export type StatusSeverity = "good" | "info" | "caution" | "alert" | "pending"; + +export interface StatusOverview { + users: { + severity: StatusSeverity; + total: number; + admins: number; + newThisWeek: number; + }; + integrations: { + severity: StatusSeverity; + withings: number; + moodLog: number; + telegram: number; + ntfy: number; + webPush: number; + }; + monitoring: { + severity: StatusSeverity; + glitchtipEnabled: boolean; + umamiEnabled: boolean; + wideEventsEnabled: boolean; + lastErrorAt: string | null; + }; + backups: { + severity: StatusSeverity; + lastBackupAt: string | null; + backedUpUsers: number; + retentionDays: number; + }; + maintenance: { + severity: StatusSeverity; + workerRunning: boolean; + workerUptimeSeconds: number | null; + lastIdempotencyCleanup: string | null; + lastAuditLogCleanup: string | null; + }; + auditLog: { + severity: StatusSeverity; + eventsLast30d: number; + lastLoginAt: string | null; + }; +} + +const ONE_DAY_MS = 24 * 60 * 60 * 1000; + +function severityForBackup(lastBackupAt: Date | null): StatusSeverity { + if (!lastBackupAt) return "alert"; + const ageMs = Date.now() - lastBackupAt.getTime(); + if (ageMs > 14 * ONE_DAY_MS) return "alert"; + if (ageMs > 8 * ONE_DAY_MS) return "caution"; + return "good"; +} + +function severityForMaintenance(workerRunning: boolean): StatusSeverity { + return workerRunning ? "good" : "alert"; +} + +function severityForMonitoring( + glitchtipEnabled: boolean, + umamiEnabled: boolean, + recentErrorAt: Date | null, +): StatusSeverity { + if (recentErrorAt && Date.now() - recentErrorAt.getTime() < ONE_DAY_MS) { + return "alert"; + } + if (glitchtipEnabled && umamiEnabled) return "good"; + if (glitchtipEnabled || umamiEnabled) return "caution"; + return "info"; +} + +function severityForIntegrations(totalConfigured: number): StatusSeverity { + if (totalConfigured === 0) return "info"; + return "good"; +} + +export const GET = apiHandler(async () => { + await requireAdmin(); + annotate({ action: { name: "admin.status-overview" } }); + + const now = new Date(); + const sevenDaysAgo = new Date(now.getTime() - 7 * ONE_DAY_MS); + const thirtyDaysAgo = new Date(now.getTime() - 30 * ONE_DAY_MS); + + // Single batched roundtrip — no N+1. + const [ + userTotal, + userAdmins, + userNewThisWeek, + withingsCount, + moodLogCount, + telegramCount, + ntfyCount, + webPushCount, + appSettings, + lastErrorEntry, + latestBackup, + backedUpUsers, + last30dEvents, + lastLoginEntry, + lastIdempotencyCleanup, + lastAuditLogCleanup, + ] = await Promise.all([ + prisma.user.count(), + prisma.user.count({ where: { role: "ADMIN" } }), + prisma.user.count({ where: { createdAt: { gte: sevenDaysAgo } } }), + prisma.withingsConnection.count(), + prisma.user.count({ where: { moodLogEnabled: true } }), + prisma.user.count({ where: { telegramEnabled: true } }), + prisma.notificationChannel.count({ + where: { type: "NTFY", enabled: true }, + }), + prisma.pushSubscription.count(), + prisma.appSettings.findUnique({ where: { id: "singleton" } }), + prisma.auditLog.findFirst({ + where: { action: { startsWith: "system.error" } }, + orderBy: { createdAt: "desc" }, + select: { createdAt: true }, + }), + prisma.dataBackup.findFirst({ + orderBy: { createdAt: "desc" }, + select: { createdAt: true }, + }), + prisma.dataBackup + .findMany({ select: { userId: true }, distinct: ["userId"] }) + .then((rows) => rows.length), + prisma.auditLog.count({ where: { createdAt: { gte: thirtyDaysAgo } } }), + prisma.auditLog.findFirst({ + where: { action: "auth.login" }, + orderBy: { createdAt: "desc" }, + select: { createdAt: true }, + }), + prisma.auditLog.findFirst({ + where: { action: "system.cleanup.idempotency" }, + orderBy: { createdAt: "desc" }, + select: { createdAt: true }, + }), + prisma.auditLog.findFirst({ + where: { action: "system.cleanup.audit-log" }, + orderBy: { createdAt: "desc" }, + select: { createdAt: true }, + }), + ]); + + const worker = getWorkerStatus(); + const workerUptimeSeconds = worker.startedAt + ? Math.max( + 0, + Math.floor( + (now.getTime() - new Date(worker.startedAt).getTime()) / 1000, + ), + ) + : null; + + const integrationsTotal = + withingsCount + moodLogCount + telegramCount + ntfyCount + webPushCount; + + const overview: StatusOverview = { + users: { + severity: "good", + total: userTotal, + admins: userAdmins, + newThisWeek: userNewThisWeek, + }, + integrations: { + severity: severityForIntegrations(integrationsTotal), + withings: withingsCount, + moodLog: moodLogCount, + telegram: telegramCount, + ntfy: ntfyCount, + webPush: webPushCount, + }, + monitoring: { + severity: severityForMonitoring( + Boolean(appSettings?.glitchtipEnabled), + Boolean(appSettings?.umamiEnabled), + lastErrorEntry?.createdAt ?? null, + ), + glitchtipEnabled: Boolean(appSettings?.glitchtipEnabled), + umamiEnabled: Boolean(appSettings?.umamiEnabled), + wideEventsEnabled: true, + lastErrorAt: lastErrorEntry?.createdAt.toISOString() ?? null, + }, + backups: { + severity: severityForBackup(latestBackup?.createdAt ?? null), + lastBackupAt: latestBackup?.createdAt.toISOString() ?? null, + backedUpUsers, + retentionDays: 90, + }, + maintenance: { + severity: severityForMaintenance(worker.running), + workerRunning: worker.running, + workerUptimeSeconds, + lastIdempotencyCleanup: + lastIdempotencyCleanup?.createdAt.toISOString() ?? null, + lastAuditLogCleanup: lastAuditLogCleanup?.createdAt.toISOString() ?? null, + }, + auditLog: { + severity: "info", + eventsLast30d: last30dEvents, + lastLoginAt: lastLoginEntry?.createdAt.toISOString() ?? null, + }, + }; + + return apiSuccess(overview); +}); diff --git a/src/components/admin/__tests__/status-card-grid.test.tsx b/src/components/admin/__tests__/status-card-grid.test.tsx new file mode 100644 index 000000000..104dcac48 --- /dev/null +++ b/src/components/admin/__tests__/status-card-grid.test.tsx @@ -0,0 +1,122 @@ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { StatusBadge, buildCards } from "../status-card-grid"; +import type { StatusOverview } from "@/app/api/admin/status-overview/route"; + +const mockOverview: StatusOverview = { + users: { severity: "good", total: 3, admins: 1, newThisWeek: 1 }, + integrations: { + severity: "good", + withings: 1, + moodLog: 0, + telegram: 1, + ntfy: 0, + webPush: 2, + }, + monitoring: { + severity: "caution", + glitchtipEnabled: true, + umamiEnabled: false, + wideEventsEnabled: true, + lastErrorAt: null, + }, + backups: { + severity: "alert", + lastBackupAt: null, + backedUpUsers: 0, + retentionDays: 90, + }, + maintenance: { + severity: "good", + workerRunning: true, + workerUptimeSeconds: 3600, + lastIdempotencyCleanup: new Date(Date.now() - 60_000).toISOString(), + lastAuditLogCleanup: null, + }, + auditLog: { + severity: "info", + eventsLast30d: 42, + lastLoginAt: new Date(Date.now() - 5 * 60_000).toISOString(), + }, +}; + +describe("", () => { + it("renders a colored dot plus a text label (never color-only)", () => { + const html = renderToStaticMarkup(); + // dot must be aria-hidden so SR users don't hear it + expect(html).toContain('aria-hidden="true"'); + // label must be present so color-blind users can read state + expect(html).toContain("Healthy"); + // wrapper carries the announcement + expect(html).toContain('aria-label="Status: Healthy"'); + }); + + it("maps severity to the §2.6 dracula color tokens", () => { + const cases: Array< + [Parameters[0]["severity"], string] + > = [ + ["good", "var(--dracula-green)"], + ["info", "var(--dracula-cyan)"], + ["caution", "var(--dracula-orange)"], + ["alert", "var(--dracula-red)"], + ["pending", "var(--muted-foreground)"], + ]; + for (const [severity, token] of cases) { + const html = renderToStaticMarkup(); + expect(html).toContain(token); + } + }); + + it("respects an explicit label override", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("Disk full"); + expect(html).toContain('aria-label="Status: Disk full"'); + }); +}); + +describe("buildCards()", () => { + it("returns exactly 6 cards in the §3.4 admin order", () => { + const cards = buildCards(mockOverview); + expect(cards).toHaveLength(6); + expect(cards.map((c) => c.title)).toEqual([ + "Users", + "Integrations", + "Monitoring", + "Backups", + "Maintenance", + "Audit log", + ]); + }); + + it("propagates severity from the aggregator response", () => { + const cards = buildCards(mockOverview); + expect(cards[0].severity).toBe("good"); // users + expect(cards[2].severity).toBe("caution"); // monitoring + expect(cards[3].severity).toBe("alert"); // backups (no backup yet) + expect(cards[5].severity).toBe("info"); // audit log + }); + + it("links each card to a manageable destination", () => { + const cards = buildCards(mockOverview); + for (const card of cards) { + expect(card.href.startsWith("/admin")).toBe(true); + expect(card.cta.length).toBeGreaterThan(0); + } + }); + + it("includes 3 metric tuples per card", () => { + const cards = buildCards(mockOverview); + for (const card of cards) { + expect(card.metrics).toHaveLength(3); + } + }); + + it("renders worker status as plain text, not color alone", () => { + const cards = buildCards(mockOverview); + const maintenance = cards.find((c) => c.title === "Maintenance"); + expect(maintenance?.metrics[0].value).toBe("Running"); + }); +}); diff --git a/src/components/admin/status-card-grid.tsx b/src/components/admin/status-card-grid.tsx new file mode 100644 index 000000000..fa226575d --- /dev/null +++ b/src/components/admin/status-card-grid.tsx @@ -0,0 +1,258 @@ +"use client"; + +import Link from "next/link"; +import { useQuery } from "@tanstack/react-query"; +import { + Users, + Plug, + Activity, + Database, + Wrench, + ScrollText, + ArrowRight, +} from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import type { + StatusOverview, + StatusSeverity, +} from "@/app/api/admin/status-overview/route"; + +/** + * §2.6 status taxonomy. Renders a colored dot AND a label so we never + * lean on color alone (WCAG 1.4.1). + */ +const SEVERITY_LABEL: Record = { + good: "Healthy", + info: "Up to date", + caution: "Attention", + alert: "Action required", + pending: "Loading", +}; + +const SEVERITY_COLOR: Record = { + good: "var(--dracula-green)", + info: "var(--dracula-cyan)", + caution: "var(--dracula-orange)", + alert: "var(--dracula-red)", + pending: "var(--muted-foreground)", +}; + +export function StatusBadge({ + severity, + label, +}: { + severity: StatusSeverity; + label?: string; +}) { + const text = label ?? SEVERITY_LABEL[severity]; + return ( + + + ); +} + +interface StatusCardProps { + title: string; + icon: React.ReactNode; + severity: StatusSeverity; + metrics: Array<{ label: string; value: string | number }>; + href: string; + cta: string; +} + +function StatusCard({ + title, + icon, + severity, + metrics, + href, + cta, +}: StatusCardProps) { + return ( + + + + + {title} + + + + +
+ {metrics.map((m) => ( +
+
{m.label}
+
{m.value}
+
+ ))} +
+ + {cta} +
+
+ ); +} + +function fmtRelative(iso: string | null): string { + if (!iso) return "never"; + const ms = Date.now() - new Date(iso).getTime(); + const min = Math.round(ms / 60_000); + if (min < 60) return `${min}m ago`; + const hr = Math.round(min / 60); + if (hr < 48) return `${hr}h ago`; + const day = Math.round(hr / 24); + return `${day}d ago`; +} + +function fmtUptime(seconds: number | null): string { + if (seconds === null) return "—"; + if (seconds < 60) return `${seconds}s`; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m`; + if (seconds < 86_400) return `${Math.floor(seconds / 3600)}h`; + return `${Math.floor(seconds / 86_400)}d`; +} + +/** + * Build the 6 admin status cards from the aggregator response. Pure; + * no data fetching here — easy to unit test. + */ +export function buildCards(data: StatusOverview): StatusCardProps[] { + return [ + { + title: "Users", + icon: , + severity: data.users.severity, + metrics: [ + { label: "Total", value: data.users.total }, + { label: "Admins", value: data.users.admins }, + { label: "New 7d", value: data.users.newThisWeek }, + ], + href: "/admin/users", + cta: "Manage users", + }, + { + title: "Integrations", + icon: , + severity: data.integrations.severity, + metrics: [ + { label: "Withings", value: data.integrations.withings }, + { label: "moodLog", value: data.integrations.moodLog }, + { label: "Push", value: data.integrations.webPush }, + ], + href: "/admin#integrations", + cta: "Configure", + }, + { + title: "Monitoring", + icon: , + severity: data.monitoring.severity, + metrics: [ + { + label: "GlitchTip", + value: data.monitoring.glitchtipEnabled ? "On" : "Off", + }, + { + label: "Umami", + value: data.monitoring.umamiEnabled ? "On" : "Off", + }, + { label: "Last err", value: fmtRelative(data.monitoring.lastErrorAt) }, + ], + href: "/admin#monitoring", + cta: "View monitoring", + }, + { + title: "Backups", + icon: , + severity: data.backups.severity, + metrics: [ + { label: "Last", value: fmtRelative(data.backups.lastBackupAt) }, + { label: "Users", value: data.backups.backedUpUsers }, + { label: "Retain", value: `${data.backups.retentionDays}d` }, + ], + href: "/admin#backups", + cta: "View backups", + }, + { + title: "Maintenance", + icon: , + severity: data.maintenance.severity, + metrics: [ + { + label: "Worker", + value: data.maintenance.workerRunning ? "Running" : "Stopped", + }, + { + label: "Uptime", + value: fmtUptime(data.maintenance.workerUptimeSeconds), + }, + { + label: "Cleanup", + value: fmtRelative(data.maintenance.lastIdempotencyCleanup), + }, + ], + href: "/admin#maintenance", + cta: "View jobs", + }, + { + title: "Audit log", + icon: , + severity: data.auditLog.severity, + metrics: [ + { label: "Events 30d", value: data.auditLog.eventsLast30d }, + { label: "Last login", value: fmtRelative(data.auditLog.lastLoginAt) }, + { label: "—", value: "" }, + ], + href: "/admin/audit-log", + cta: "Open viewer", + }, + ]; +} + +export function StatusCardGrid() { + const { data, isLoading } = useQuery({ + queryKey: ["admin", "status-overview"], + queryFn: async () => { + const res = await fetch("/api/admin/status-overview"); + if (!res.ok) throw new Error("Failed to load admin status overview"); + const json = (await res.json()) as { data: StatusOverview }; + return json.data; + }, + staleTime: 30_000, + }); + + if (isLoading || !data) { + return ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+ ); + } + + const cards = buildCards(data); + return ( +
+ {cards.map((c) => ( + + ))} +
+ ); +}