Skip to content

Commit 3ade2dd

Browse files
authored
fix(ui): add a content-shaped loading skeleton to the analytics dashboard (#6949)
app.analytics.tsx's StateBoundary had no loadingSkeleton, falling through to the generic centered spinner -- the same gap #6816 fixed for app.operator.tsx, relative to app.runs.tsx/miner-panel.tsx/ maintainer-panel.tsx/dead-letter-queue-panel.tsx. Adds AnalyticsDashboardSkeleton mirroring the real layout (header + window toggle, stat grid, card sections) and wires it via loadingSkeleton on the existing StateBoundary. Closes #6817
1 parent d6cf892 commit 3ade2dd

2 files changed

Lines changed: 72 additions & 1 deletion

File tree

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { render, screen } from "@testing-library/react";
2+
import { describe, expect, it, vi } from "vitest";
3+
4+
// #6817: app.analytics.tsx's StateBoundary had no loadingSkeleton, falling through to the generic spinner
5+
// -- the same gap #6816 fixed for app.operator.tsx.
6+
const { useApiResource } = vi.hoisted(() => ({ useApiResource: vi.fn() }));
7+
vi.mock("@/lib/api/use-api-resource", () => ({
8+
useApiResource: (...args: unknown[]) => useApiResource(...args),
9+
}));
10+
11+
import { ProductAnalytics } from "@/routes/app.analytics";
12+
13+
describe("ProductAnalytics loading skeleton (#6817)", () => {
14+
it("shows a content-shaped skeleton (not the generic spinner) while the dashboard loads", () => {
15+
useApiResource.mockReturnValue({
16+
status: "loading",
17+
data: null,
18+
error: null,
19+
loadedAt: null,
20+
reload: () => {},
21+
});
22+
23+
const { container } = render(<ProductAnalytics />);
24+
// The custom skeleton replaces the generic LoadingState — neither its title nor its spinner shows.
25+
expect(screen.queryByText("Loading analytics…")).toBeNull();
26+
expect(container.querySelector(".animate-spin")).toBeNull();
27+
// The placeholder renders animate-pulse blocks approximating the dashboard's header + stat + card grid.
28+
expect(container.querySelectorAll(".animate-pulse").length).toBeGreaterThan(1);
29+
});
30+
31+
it("does not show the skeleton once the dashboard has real data", () => {
32+
useApiResource.mockReturnValue({
33+
status: "ready",
34+
data: { metrics: [{ label: "Active repos", value: "12", delta: "+2" }], noiseReduction: [] },
35+
error: null,
36+
loadedAt: "2026-07-17T00:00:00.000Z",
37+
reload: () => {},
38+
});
39+
40+
const { container } = render(<ProductAnalytics />);
41+
expect(screen.getByText("Usage & value analytics")).toBeTruthy();
42+
expect(container.querySelectorAll(".animate-pulse").length).toBe(0);
43+
});
44+
});

apps/loopover-ui/src/routes/app.analytics.tsx

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { BoundaryBadge, Stat, StatusPill } from "@/components/site/control-primi
55
import { TableScroll } from "@/components/site/data-table";
66
import { RefreshMeta } from "@/components/site/refresh-meta";
77
import { StateActionButton, StateBoundary } from "@/components/site/state-views";
8+
import { Skeleton } from "@/components/ui/skeleton";
89
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
910
import { TrendChart } from "@/components/site/trend-chart";
1011
import {
@@ -142,7 +143,32 @@ type OperatorDashboard = {
142143
slopCalibration?: SlopOutcomeCalibration;
143144
};
144145

145-
function ProductAnalytics() {
146+
function AnalyticsDashboardSkeleton() {
147+
return (
148+
<div className="space-y-8" aria-hidden>
149+
<div className="flex flex-wrap items-end justify-between gap-3">
150+
<div className="space-y-2">
151+
<Skeleton className="h-3 w-20 rounded-token" />
152+
<Skeleton className="h-7 w-72 rounded-token" />
153+
<Skeleton className="h-4 w-96 rounded-token" />
154+
</div>
155+
<Skeleton className="h-8 w-40 rounded-token" />
156+
</div>
157+
<section className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
158+
{Array.from({ length: 3 }, (_, index) => (
159+
<Skeleton key={index} className="h-24 w-full rounded-token" />
160+
))}
161+
</section>
162+
<section className="grid gap-6 lg:grid-cols-2">
163+
{Array.from({ length: 4 }, (_, index) => (
164+
<Skeleton key={index} className="h-48 w-full rounded-token" />
165+
))}
166+
</section>
167+
</div>
168+
);
169+
}
170+
171+
export function ProductAnalytics() {
146172
const [windowDays, setWindowDays, windowHydrated] = useLocalStorage<AnalyticsWindowDays>(
147173
ANALYTICS_WINDOW_STORAGE_KEY,
148174
DEFAULT_ANALYTICS_WINDOW_DAYS,
@@ -168,6 +194,7 @@ function ProductAnalytics() {
168194
onRetry={dashboard.reload}
169195
onRefresh={dashboard.reload}
170196
loadingTitle="Loading analytics…"
197+
loadingSkeleton={<AnalyticsDashboardSkeleton />}
171198
emptyTitle="No analytics yet"
172199
emptyDescription="Aggregate adoption and command usage metrics will appear once the API has data."
173200
errorDescription={dashboard.status === "error" ? dashboard.error : undefined}

0 commit comments

Comments
 (0)