From 7ff80e4195c28c5958779aa39b79c50d3cba2c6a Mon Sep 17 00:00:00 2001
From: dhgoal <153369624+dhgoal@users.noreply.github.com>
Date: Fri, 10 Jul 2026 23:05:30 +0900
Subject: [PATCH] feat(ui): add shared analytics-card skeleton + empty-state
shell (#2200)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
New AnalyticsCardShell: a titled analytics card that renders one of three states — a skeleton shimmer
while the metric loads, an EmptyState with a hint when it has no data, or its ready content — so the
analytics cards share one loading/empty treatment instead of each re-inventing it. Presentational only.
Adopted in the analytics route: the cycle-time slot now shows the shared empty card when its data is
absent instead of rendering nothing.
Closes #2200
---
.../app-panels/analytics-card-shell.test.tsx | 53 +++++++++++++++++++
.../site/app-panels/analytics-card-shell.tsx | 51 ++++++++++++++++++
.../src/routes/app.analytics.tsx | 12 ++++-
3 files changed, 115 insertions(+), 1 deletion(-)
create mode 100644 apps/gittensory-ui/src/components/site/app-panels/analytics-card-shell.test.tsx
create mode 100644 apps/gittensory-ui/src/components/site/app-panels/analytics-card-shell.tsx
diff --git a/apps/gittensory-ui/src/components/site/app-panels/analytics-card-shell.test.tsx b/apps/gittensory-ui/src/components/site/app-panels/analytics-card-shell.test.tsx
new file mode 100644
index 0000000000..98053ebc20
--- /dev/null
+++ b/apps/gittensory-ui/src/components/site/app-panels/analytics-card-shell.test.tsx
@@ -0,0 +1,53 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { AnalyticsCardShell } from "@/components/site/app-panels/analytics-card-shell";
+
+describe("AnalyticsCardShell", () => {
+ it("renders the title, description, and the ready slot content when state is ready", () => {
+ render(
+
+ ready content
+ ,
+ );
+ expect(screen.getByRole("heading", { name: "Queue health" })).toBeTruthy();
+ expect(screen.getByText("pending / in-flight / stuck")).toBeTruthy();
+ expect(screen.getByText("ready content")).toBeTruthy();
+ });
+
+ it("renders the empty state with its title and hint, and no ready content, when state is empty", () => {
+ render(
+
+ ready content
+ ,
+ );
+ expect(screen.getByText("No snapshot yet")).toBeTruthy();
+ expect(screen.getByText("Runs once the queue reports.")).toBeTruthy();
+ expect(screen.queryByText("ready content")).toBeNull();
+ });
+
+ it("renders skeleton placeholders (and no ready content) when state is loading", () => {
+ const { container } = render(
+
+ ready content
+ ,
+ );
+ expect(screen.queryByText("ready content")).toBeNull();
+ expect(container.querySelectorAll(".animate-pulse").length).toBeGreaterThan(0);
+ });
+
+ it("omits the description paragraph when none is provided", () => {
+ const { container } = render();
+ expect(screen.getByRole("heading", { name: "Queue health" })).toBeTruthy();
+ expect(container.querySelectorAll("p").length).toBe(0);
+ });
+});
diff --git a/apps/gittensory-ui/src/components/site/app-panels/analytics-card-shell.tsx b/apps/gittensory-ui/src/components/site/app-panels/analytics-card-shell.tsx
new file mode 100644
index 0000000000..46e9da1141
--- /dev/null
+++ b/apps/gittensory-ui/src/components/site/app-panels/analytics-card-shell.tsx
@@ -0,0 +1,51 @@
+import type { ReactNode } from "react";
+
+import { EmptyState } from "@/components/site/state-views";
+import { Skeleton } from "@/components/ui/skeleton";
+
+/** Shared analytics-card treatment (#2200): a titled card that renders one of three states — a skeleton
+ * shimmer while the metric loads, an EmptyState with a hint when it has no data, or its ready content — so
+ * every analytics card shares one loading/empty look instead of each re-inventing it. Presentational only:
+ * the caller decides the state from its own data. */
+export type AnalyticsCardState = "loading" | "empty" | "ready";
+
+export function AnalyticsCardShell({
+ title,
+ description,
+ state,
+ emptyTitle = "No data yet",
+ emptyHint,
+ children,
+}: {
+ title: string;
+ description?: ReactNode;
+ state: AnalyticsCardState;
+ emptyTitle?: string;
+ emptyHint?: ReactNode;
+ children?: ReactNode;
+}) {
+ return (
+
+
+
+
{title}
+ {description ? (
+
{description}
+ ) : null}
+
+
+
+ {state === "loading" ? (
+
+
+
+
+
+ ) : state === "empty" ? (
+
+ ) : (
+ {children}
+ )}
+
+ );
+}
diff --git a/apps/gittensory-ui/src/routes/app.analytics.tsx b/apps/gittensory-ui/src/routes/app.analytics.tsx
index ec1d4b16b9..5d8136fac7 100644
--- a/apps/gittensory-ui/src/routes/app.analytics.tsx
+++ b/apps/gittensory-ui/src/routes/app.analytics.tsx
@@ -13,6 +13,7 @@ import { GatePrecisionCard } from "@/components/site/app-panels/gate-precision-c
import type { GateEvalReport } from "@/components/site/app-panels/gate-precision-card-model";
import { CycleTimeCard } from "@/components/site/app-panels/cycle-time-card";
import type { CycleTimeAggregate } from "@/components/site/app-panels/cycle-time-card-model";
+import { AnalyticsCardShell } from "@/components/site/app-panels/analytics-card-shell";
import { useApiResource } from "@/lib/api/use-api-resource";
export const Route = createFileRoute("/app/analytics")({
@@ -186,7 +187,16 @@ function ProductAnalytics() {
{data.gateEval ? : null}
- {data.cycleTime ? : null}
+ {data.cycleTime ? (
+
+ ) : (
+
+ )}
{data.usageSummary ? (