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 ? (