From 059356893c85450d9825e5354faafdb952ea3f32 Mon Sep 17 00:00:00 2001 From: nghetienhiep Date: Thu, 16 Jul 2026 09:25:49 +0000 Subject: [PATCH] fix(ui): adopt AnalyticsCardShell in five hand-rolled analytics cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cycle-time, gate-precision, reversal-health, gate-outcome, and slop-duplicate-trend cards each re-implemented the shared "titled card with loading/empty/ready" chrome (#2200) instead of rendering through AnalyticsCardShell like acceptance-rate and queue-health already do. The root-cause symptom was CycleTimeCard's no-samples branch: it rendered a bare paragraph instead of the shared EmptyState every adopted sibling gets for free. Routing it through the shell with state="empty" fixes that and gives the whole folder one loading/empty/ready implementation. To keep each card's header signal (status pill, public boundary badge, freshness stamp) the shell gains an optional `action` slot rendered in the header across every state — the header already reserved the right-hand space via justify-between. Data-fetching and ready-state content are unchanged; only the chrome/state wrapper moved. Closes #6175 --- .../app-panels/analytics-card-shell.test.tsx | 16 +++ .../site/app-panels/analytics-card-shell.tsx | 4 + .../site/app-panels/cycle-time-card.test.tsx | 8 +- .../site/app-panels/cycle-time-card.tsx | 80 +++++------- .../site/app-panels/gate-outcome-card.tsx | 26 ++-- .../site/app-panels/gate-precision-card.tsx | 27 ++-- .../site/app-panels/reversal-health-card.tsx | 26 ++-- .../app-panels/slop-duplicate-trend-card.tsx | 122 +++++++++--------- 8 files changed, 155 insertions(+), 154 deletions(-) diff --git a/apps/loopover-ui/src/components/site/app-panels/analytics-card-shell.test.tsx b/apps/loopover-ui/src/components/site/app-panels/analytics-card-shell.test.tsx index 98053ebc20..e6ff005d1a 100644 --- a/apps/loopover-ui/src/components/site/app-panels/analytics-card-shell.test.tsx +++ b/apps/loopover-ui/src/components/site/app-panels/analytics-card-shell.test.tsx @@ -50,4 +50,20 @@ describe("AnalyticsCardShell", () => { expect(screen.getByRole("heading", { name: "Queue health" })).toBeTruthy(); expect(container.querySelectorAll("p").length).toBe(0); }); + + it("renders the header action across every state, including empty", () => { + render( + 12 paired} + emptyTitle="No snapshot yet" + > +
ready content
+
, + ); + expect(screen.getByText("12 paired")).toBeTruthy(); + expect(screen.getByText("No snapshot yet")).toBeTruthy(); + expect(screen.queryByText("ready content")).toBeNull(); + }); }); diff --git a/apps/loopover-ui/src/components/site/app-panels/analytics-card-shell.tsx b/apps/loopover-ui/src/components/site/app-panels/analytics-card-shell.tsx index 46e9da1141..bb8f1bf477 100644 --- a/apps/loopover-ui/src/components/site/app-panels/analytics-card-shell.tsx +++ b/apps/loopover-ui/src/components/site/app-panels/analytics-card-shell.tsx @@ -13,6 +13,7 @@ export function AnalyticsCardShell({ title, description, state, + action, emptyTitle = "No data yet", emptyHint, children, @@ -20,6 +21,8 @@ export function AnalyticsCardShell({ title: string; description?: ReactNode; state: AnalyticsCardState; + /** Optional header-right chrome (a status pill, boundary badge, freshness stamp) rendered across every state. */ + action?: ReactNode; emptyTitle?: string; emptyHint?: ReactNode; children?: ReactNode; @@ -33,6 +36,7 @@ export function AnalyticsCardShell({

{description}

) : null} + {action ?
{action}
: null} {state === "loading" ? ( diff --git a/apps/loopover-ui/src/components/site/app-panels/cycle-time-card.test.tsx b/apps/loopover-ui/src/components/site/app-panels/cycle-time-card.test.tsx index 550847c8f5..5570a530bf 100644 --- a/apps/loopover-ui/src/components/site/app-panels/cycle-time-card.test.tsx +++ b/apps/loopover-ui/src/components/site/app-panels/cycle-time-card.test.tsx @@ -51,7 +51,7 @@ describe("CycleTimeCard", () => { expect(screen.getByText("2 paired PR(s)")).toBeTruthy(); }); - it("shows inline empty copy when there are no samples", () => { + it("shows the shared EmptyState (not the percentile tiles) when there are no samples", () => { const cycleTime: CycleTimeAggregate = { p50Ms: null, p90Ms: null, @@ -61,6 +61,12 @@ describe("CycleTimeCard", () => { }; render(); expect(screen.getByText("no samples yet")).toBeTruthy(); + // Empty case now renders the shared EmptyState rather than a bare paragraph or the p50/p90/p99 tiles. + expect(screen.getByText("No paired samples yet")).toBeTruthy(); + expect( + screen.getByText(/Paired gate decisions and PR outcomes will appear here/i), + ).toBeTruthy(); + expect(screen.queryByText("p50")).toBeNull(); expect(screen.queryByText("Cycle-time distribution")).toBeNull(); }); diff --git a/apps/loopover-ui/src/components/site/app-panels/cycle-time-card.tsx b/apps/loopover-ui/src/components/site/app-panels/cycle-time-card.tsx index 08a7bc2443..c9606e28de 100644 --- a/apps/loopover-ui/src/components/site/app-panels/cycle-time-card.tsx +++ b/apps/loopover-ui/src/components/site/app-panels/cycle-time-card.tsx @@ -1,3 +1,4 @@ +import { AnalyticsCardShell } from "@/components/site/app-panels/analytics-card-shell"; import { MiniSparkbar, Stat, StatusPill } from "@/components/site/control-primitives"; import { formatCycleTimeMs, @@ -5,59 +6,48 @@ import { } from "@/components/site/app-panels/cycle-time-card-model"; /** Self-host maintainer analytics card (#2194): PR review cycle-time percentiles (p50/p90/p99) from the stats - * feed, read-only over the operator-dashboard payload. Shows an inline empty state when there are no paired - * gate_decision → pr_outcome samples in the window. */ + * feed, read-only over the operator-dashboard payload. Renders through the shared AnalyticsCardShell (#2200) so + * the no-samples case shows the standard EmptyState instead of bare copy. */ export function CycleTimeCard({ cycleTime }: { cycleTime: CycleTimeAggregate }) { const hasSamples = cycleTime.sampleSize > 0; const hasDistribution = cycleTime.distribution.length > 0; return ( -
-
-
-

Review cycle time

-

- Gate decision → PR outcome duration percentiles from review_audit. Public-safe - aggregates only. -

-
+ {hasSamples ? `${cycleTime.sampleSize} paired PR(s)` : "no samples yet"} + } + emptyTitle="No paired samples yet" + emptyHint="Paired gate decisions and PR outcomes will appear here once the gate has resolved pull requests in the analytics window." + > +
+ median cycle time} + /> + 90th percentile} + /> + 99th percentile} + />
- - {hasSamples ? ( - <> -
- median cycle time} - /> - 90th percentile} - /> - 99th percentile} - /> -
- {hasDistribution ? ( -
-
Cycle-time distribution
- -
- ) : null} - - ) : ( -

- Paired gate decisions and PR outcomes will appear here once the gate has resolved pull - requests in the analytics window. -

- )} -
+ {hasDistribution ? ( +
+
Cycle-time distribution
+ +
+ ) : null} + ); } diff --git a/apps/loopover-ui/src/components/site/app-panels/gate-outcome-card.tsx b/apps/loopover-ui/src/components/site/app-panels/gate-outcome-card.tsx index 5ecaca2b6a..7795903a13 100644 --- a/apps/loopover-ui/src/components/site/app-panels/gate-outcome-card.tsx +++ b/apps/loopover-ui/src/components/site/app-panels/gate-outcome-card.tsx @@ -1,3 +1,4 @@ +import { AnalyticsCardShell } from "@/components/site/app-panels/analytics-card-shell"; import { BoundaryBadge, Stat } from "@/components/site/control-primitives"; import { EmptyState } from "@/components/site/state-views"; import { @@ -8,25 +9,20 @@ import { } from "@/components/site/app-panels/gate-outcome-card-model"; /** Gate-outcome breakdown card (#2203, part of #539): auto-merged / auto-closed / held counts and rates - * from repo-scoped gate-outcome audit events. Read-only; public-safe aggregate counts only. */ + * from repo-scoped gate-outcome audit events. Renders through the shared AnalyticsCardShell (#2200); the count + * stats always show, and the outcome-mix bar falls back to an EmptyState when there are no events. */ export function GateOutcomeCard({ breakdown }: { breakdown: GateOutcomeCardData }) { const segments = gateOutcomeSegments(breakdown); const hasSamples = gateOutcomeHasSamples(breakdown); return ( -
-
-
-

Gate outcomes

-

- Terminal gate dispositions from audit events over the last {breakdown.windowDays}{" "} - day(s). -

-
- -
- -
+ } + > +
)} -
+ ); } diff --git a/apps/loopover-ui/src/components/site/app-panels/gate-precision-card.tsx b/apps/loopover-ui/src/components/site/app-panels/gate-precision-card.tsx index f05e3d4886..e41dda50ff 100644 --- a/apps/loopover-ui/src/components/site/app-panels/gate-precision-card.tsx +++ b/apps/loopover-ui/src/components/site/app-panels/gate-precision-card.tsx @@ -1,4 +1,5 @@ import { cn } from "@/lib/utils"; +import { AnalyticsCardShell } from "@/components/site/app-panels/analytics-card-shell"; import { Stat, StatusPill } from "@/components/site/control-primitives"; import { aggregateGateEval, type GateEvalReport } from "./gate-precision-card-model"; @@ -7,28 +8,26 @@ import { aggregateGateEval, type GateEvalReport } from "./gate-precision-card-mo const MIN_DECIDED_FLOOR = 10; /** Self-host maintainer analytics card (#2191): gate merge-precision + the TP/FP/FN/TN confusion matrix from - * computeGateEval, read-only over the operator-dashboard payload. Renders nothing when there are no evaluated - * projects at all (keeps the analytics page clean until the gate has produced eval rows). */ + * computeGateEval, read-only over the operator-dashboard payload. Renders through the shared AnalyticsCardShell + * (#2200), but renders nothing when there are no evaluated projects at all (keeps the analytics page clean until + * the gate has produced eval rows). */ export function GatePrecisionCard({ report }: { report: GateEvalReport }) { if (report.rows.length === 0) return null; const matrix = aggregateGateEval(report); return ( -
-
-
-

Gate precision

-

- The gate's merge/close predictions scored against realized PR outcomes. Public-safe - counts only. -

-
+ {report.hasSignal ? `${matrix.decided} decided` : `below ${MIN_DECIDED_FLOOR}-sample floor`} -
-
+ } + > +
-
+ ); } diff --git a/apps/loopover-ui/src/components/site/app-panels/reversal-health-card.tsx b/apps/loopover-ui/src/components/site/app-panels/reversal-health-card.tsx index fd711f190e..9327bbbb88 100644 --- a/apps/loopover-ui/src/components/site/app-panels/reversal-health-card.tsx +++ b/apps/loopover-ui/src/components/site/app-panels/reversal-health-card.tsx @@ -1,3 +1,4 @@ +import { AnalyticsCardShell } from "@/components/site/app-panels/analytics-card-shell"; import { Stat, StatusPill } from "@/components/site/control-primitives"; import { EmptyState } from "@/components/site/state-views"; import { @@ -8,25 +9,20 @@ import { } from "@/components/site/app-panels/reversal-health-card-model"; /** Analytics card (#2193): reversal rate and recent auto-action health from computeAgentHealth — read-only - * over the operator-dashboard payload. Lists reversed targets when present; EmptyState when none. */ + * over the operator-dashboard payload. Renders through the shared AnalyticsCardShell (#2200); the rate stats + * always show, and the reversed-targets list falls back to an EmptyState when none. */ export function ReversalHealthCard({ health }: { health: ReversalHealth }) { const status = reversalHealthStatus(health); const reversedTargets = health.reversedTargets ?? []; return ( -
-
-
-

Reversal health

-

- How often humans reopened or reverted a bot auto-action in the last 7 days. Public-safe - counts only. -

-
- {status.label} -
- -
+ {status.label}} + > +
)} -
+ ); } diff --git a/apps/loopover-ui/src/components/site/app-panels/slop-duplicate-trend-card.tsx b/apps/loopover-ui/src/components/site/app-panels/slop-duplicate-trend-card.tsx index a381f23997..d11389a5ce 100644 --- a/apps/loopover-ui/src/components/site/app-panels/slop-duplicate-trend-card.tsx +++ b/apps/loopover-ui/src/components/site/app-panels/slop-duplicate-trend-card.tsx @@ -1,3 +1,4 @@ +import { AnalyticsCardShell } from "@/components/site/app-panels/analytics-card-shell"; import { StatusPill } from "@/components/site/control-primitives"; import { TrendChart } from "@/components/site/trend-chart"; import { @@ -20,7 +21,8 @@ const SLOP_BAND_TONE: Record = { }; /** Maintainer quality dashboard card (#2202): weekly slop-flag and duplicate-flag rates from queue-health - * snapshots. Band labels only — never raw slop-risk or credibility numbers. */ + * snapshots. Band labels only — never raw slop-risk or credibility numbers. Renders through the shared + * AnalyticsCardShell (#2200), so the no-signal case shows the standard EmptyState. */ export function SlopDuplicateTrendCard({ trend }: { trend: MaintainerSlopDuplicateTrend }) { const hasSignal = trendHasAnySignal(trend.weeks); const hasSlop = seriesHasSignal(trend.weeks, "slop"); @@ -28,78 +30,70 @@ export function SlopDuplicateTrendCard({ trend }: { trend: MaintainerSlopDuplica const latest = latestWeekWithSignal(trend.weeks); return ( -
-
-
-

Slop + duplicate trend

-

- Weekly slop-flag and duplicate-flag rates from queue-health snapshots. Band labels only. -

-
-
+ {trend.stale ? "stale snapshot" : "fresh snapshot"} generated {formatGeneratedAt(trend.generatedAt)} + + } + emptyTitle="No snapshot history yet" + emptyHint="Queue-health snapshot history will appear here after signal snapshot jobs run for your scoped repositories." + > + <> +
+ +
-
- - {hasSignal ? ( - <> -
- - -
-
- - -
+
+ + +
-

{trend.summary}

- - ) : ( -

- Queue-health snapshot history will appear here after signal snapshot jobs run for your - scoped repositories. -

- )} -
+

{trend.summary}

+ + ); }