Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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(
<AnalyticsCardShell
title="Queue health"
description="pending / in-flight / stuck"
state="ready"
>
<div>ready content</div>
</AnalyticsCardShell>,
);
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(
<AnalyticsCardShell
title="Queue health"
state="empty"
emptyTitle="No snapshot yet"
emptyHint="Runs once the queue reports."
>
<div>ready content</div>
</AnalyticsCardShell>,
);
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(
<AnalyticsCardShell title="Queue health" state="loading">
<div>ready content</div>
</AnalyticsCardShell>,
);
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(<AnalyticsCardShell title="Queue health" state="ready" />);
expect(screen.getByRole("heading", { name: "Queue health" })).toBeTruthy();
expect(container.querySelectorAll("p").length).toBe(0);
});
});
Original file line number Diff line number Diff line change
@@ -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 (
<section className="rounded-token border border-border bg-transparent p-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="font-display text-token-lg font-semibold">{title}</h2>
{description ? (
<p className="mt-1 text-token-xs text-muted-foreground">{description}</p>
) : null}
</div>
</div>

{state === "loading" ? (
<div className="mt-4 grid gap-3 sm:grid-cols-3" aria-hidden>
<Skeleton className="h-16" />
<Skeleton className="h-16" />
<Skeleton className="h-16" />
</div>
) : state === "empty" ? (
<EmptyState className="mt-4" title={emptyTitle} description={emptyHint} />
) : (
<div className="mt-4">{children}</div>
)}
</section>
);
}
12 changes: 11 additions & 1 deletion apps/gittensory-ui/src/routes/app.analytics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
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")({
Expand Down Expand Up @@ -106,7 +107,7 @@
cycleTime?: CycleTimeAggregate;
};

function ProductAnalytics() {

Check warning on line 110 in apps/gittensory-ui/src/routes/app.analytics.tsx

View workflow job for this annotation

GitHub Actions / validate-code

Fast refresh only works when a file only exports components. Move your component(s) to a separate file. If all exports are HOCs, add them to the `extraHOCs` option
const dashboard = useApiResource<OperatorDashboard>(
"/v1/app/operator-dashboard",
"Product analytics",
Expand Down Expand Up @@ -186,7 +187,16 @@

{data.gateEval ? <GatePrecisionCard report={data.gateEval} /> : null}

{data.cycleTime ? <CycleTimeCard cycleTime={data.cycleTime} /> : null}
{data.cycleTime ? (
<CycleTimeCard cycleTime={data.cycleTime} />
) : (
<AnalyticsCardShell
title="Review cycle time"
description="Gate decision → PR outcome duration percentiles."
state="empty"
emptyHint="Percentiles appear once the gate has resolved paired PRs in the analytics window."
/>
)}

{data.usageSummary ? (
<ProductUsageBreakdownPanel
Expand Down
Loading