Skip to content
Closed
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
Expand Up @@ -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(
<AnalyticsCardShell
title="Queue health"
state="empty"
action={<span>12 paired</span>}
emptyTitle="No snapshot yet"
>
<div>ready content</div>
</AnalyticsCardShell>,
);
expect(screen.getByText("12 paired")).toBeTruthy();
expect(screen.getByText("No snapshot yet")).toBeTruthy();
expect(screen.queryByText("ready content")).toBeNull();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,16 @@ export function AnalyticsCardShell({
title,
description,
state,
action,
emptyTitle = "No data yet",
emptyHint,
children,
}: {
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;
Expand All @@ -33,6 +36,7 @@ export function AnalyticsCardShell({
<p className="mt-1 text-token-xs text-muted-foreground">{description}</p>
) : null}
</div>
{action ? <div className="flex flex-wrap items-center gap-2">{action}</div> : null}
</div>

{state === "loading" ? (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -61,6 +61,12 @@ describe("CycleTimeCard", () => {
};
render(<CycleTimeCard cycleTime={cycleTime} />);
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();
});

Expand Down
Original file line number Diff line number Diff line change
@@ -1,63 +1,53 @@
import { AnalyticsCardShell } from "@/components/site/app-panels/analytics-card-shell";
import { MiniSparkbar, Stat, StatusPill } from "@/components/site/control-primitives";
import {
formatCycleTimeMs,
type CycleTimeAggregate,
} 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 (
<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">Review cycle time</h2>
<p className="mt-1 text-token-xs text-muted-foreground">
Gate decision → PR outcome duration percentiles from review_audit. Public-safe
aggregates only.
</p>
</div>
<AnalyticsCardShell
title="Review cycle time"
description="Gate decision → PR outcome duration percentiles from review_audit. Public-safe aggregates only."
state={hasSamples ? "ready" : "empty"}
action={
<StatusPill status={hasSamples ? "ready" : "info"}>
{hasSamples ? `${cycleTime.sampleSize} paired PR(s)` : "no samples yet"}
</StatusPill>
}
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."
>
<div className="grid gap-3 sm:grid-cols-3">
<Stat
label="p50"
value={formatCycleTimeMs(cycleTime.p50Ms)}
hint={<span className="text-muted-foreground">median cycle time</span>}
/>
<Stat
label="p90"
value={formatCycleTimeMs(cycleTime.p90Ms)}
hint={<span className="text-muted-foreground">90th percentile</span>}
/>
<Stat
label="p99"
value={formatCycleTimeMs(cycleTime.p99Ms)}
hint={<span className="text-muted-foreground">99th percentile</span>}
/>
</div>

{hasSamples ? (
<>
<div className="mt-4 grid gap-3 sm:grid-cols-3">
<Stat
label="p50"
value={formatCycleTimeMs(cycleTime.p50Ms)}
hint={<span className="text-muted-foreground">median cycle time</span>}
/>
<Stat
label="p90"
value={formatCycleTimeMs(cycleTime.p90Ms)}
hint={<span className="text-muted-foreground">90th percentile</span>}
/>
<Stat
label="p99"
value={formatCycleTimeMs(cycleTime.p99Ms)}
hint={<span className="text-muted-foreground">99th percentile</span>}
/>
</div>
{hasDistribution ? (
<div className="mt-4 rounded-token border border-border bg-background/40 p-3">
<div className="text-token-xs text-muted-foreground">Cycle-time distribution</div>
<MiniSparkbar values={cycleTime.distribution} className="mt-2" />
</div>
) : null}
</>
) : (
<p className="mt-4 text-token-sm text-muted-foreground">
Paired gate decisions and PR outcomes will appear here once the gate has resolved pull
requests in the analytics window.
</p>
)}
</section>
{hasDistribution ? (
<div className="mt-4 rounded-token border border-border bg-background/40 p-3">
<div className="text-token-xs text-muted-foreground">Cycle-time distribution</div>
<MiniSparkbar values={cycleTime.distribution} className="mt-2" />
</div>
) : null}
</AnalyticsCardShell>
);
}
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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 (
<section className="rounded-token border-hairline bg-card p-5">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="font-display text-token-lg font-semibold">Gate outcomes</h2>
<p className="mt-1 text-token-xs text-muted-foreground">
Terminal gate dispositions from audit events over the last {breakdown.windowDays}{" "}
day(s).
</p>
</div>
<BoundaryBadge boundary="public" />
</div>

<div className="mt-4 grid gap-3 sm:grid-cols-3">
<AnalyticsCardShell
title="Gate outcomes"
description={`Terminal gate dispositions from audit events over the last ${breakdown.windowDays} day(s).`}
state="ready"
action={<BoundaryBadge boundary="public" />}
>
<div className="grid gap-3 sm:grid-cols-3">
<Stat
label="Auto-merged"
value={String(breakdown.counts.autoMerged)}
Expand Down Expand Up @@ -94,6 +90,6 @@ export function GateOutcomeCard({ breakdown }: { breakdown: GateOutcomeCardData
description="Auto-merge, auto-close, and hold audit rows appear here once the agent processes PRs in your scoped repos."
/>
)}
</section>
</AnalyticsCardShell>
);
}
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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 (
<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">Gate precision</h2>
<p className="mt-1 text-token-xs text-muted-foreground">
The gate's merge/close predictions scored against realized PR outcomes. Public-safe
counts only.
</p>
</div>
<AnalyticsCardShell
title="Gate precision"
description="The gate's merge/close predictions scored against realized PR outcomes. Public-safe counts only."
state="ready"
action={
<StatusPill status={report.hasSignal ? "ready" : "warn"}>
{report.hasSignal
? `${matrix.decided} decided`
: `below ${MIN_DECIDED_FLOOR}-sample floor`}
</StatusPill>
</div>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
}
>
<div className="grid gap-3 sm:grid-cols-2">
<Stat
label="Merge precision"
value={
Expand Down Expand Up @@ -68,7 +67,7 @@ export function GatePrecisionCard({ report }: { report: GateEvalReport }) {
tone="text-success"
/>
</div>
</section>
</AnalyticsCardShell>
);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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 (
<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">Reversal health</h2>
<p className="mt-1 text-token-xs text-muted-foreground">
How often humans reopened or reverted a bot auto-action in the last 7 days. Public-safe
counts only.
</p>
</div>
<StatusPill status={status.tone}>{status.label}</StatusPill>
</div>

<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<AnalyticsCardShell
title="Reversal health"
description="How often humans reopened or reverted a bot auto-action in the last 7 days. Public-safe counts only."
state="ready"
action={<StatusPill status={status.tone}>{status.label}</StatusPill>}
>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Stat
label="Reversal rate"
value={formatRatePct(health.reversalRate)}
Expand Down Expand Up @@ -81,6 +77,6 @@ export function ReversalHealthCard({ health }: { health: ReversalHealth }) {
description="When a contributor reopens a bot-close or reverts a bot-merge, the pull request will appear here."
/>
)}
</section>
</AnalyticsCardShell>
);
}
Loading
Loading