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
Expand Up @@ -50,4 +50,25 @@ describe("AnalyticsCardShell", () => {
expect(screen.getByRole("heading", { name: "Queue health" })).toBeTruthy();
expect(container.querySelectorAll("p").length).toBe(0);
});

it("renders the action header slot across every state (loading, empty, ready)", () => {
for (const state of ["loading", "empty", "ready"] as const) {
const { unmount } = render(
<AnalyticsCardShell title="Queue health" state={state} action={<span>action slot</span>}>
<div>ready content</div>
</AnalyticsCardShell>,
);
expect(screen.getByText("action slot")).toBeTruthy();
unmount();
}
});

it("omits the action slot entirely when none is provided", () => {
render(
<AnalyticsCardShell title="Queue health" state="ready">
<div>ready content</div>
</AnalyticsCardShell>,
);
expect(screen.queryByText("action slot")).toBeNull();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,17 @@ export function AnalyticsCardShell({
state,
emptyTitle = "No data yet",
emptyHint,
action,
children,
}: {
title: string;
description?: ReactNode;
state: AnalyticsCardState;
emptyTitle?: string;
emptyHint?: ReactNode;
/** Header-right slot (e.g. a status pill, boundary badge, or freshness stamp) shown across every
* state, matching the header's existing `justify-between` layout. */
action?: ReactNode;
children?: ReactNode;
}) {
return (
Expand All @@ -33,6 +37,7 @@ export function AnalyticsCardShell({
<p className="mt-1 text-token-xs text-muted-foreground">{description}</p>
) : null}
</div>
{action}
</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 a bare paragraph) when there are no samples (#6175)", () => {
const cycleTime: CycleTimeAggregate = {
p50Ms: null,
p90Ms: null,
Expand All @@ -61,7 +61,14 @@ describe("CycleTimeCard", () => {
};
render(<CycleTimeCard cycleTime={cycleTime} />);
expect(screen.getByText("no samples yet")).toBeTruthy();
expect(screen.getByText("No paired samples yet")).toBeTruthy();
expect(
screen.getByText(
"Paired gate decisions and PR outcomes will appear here once the gate has resolved pull requests in the analytics window.",
),
).toBeTruthy();
expect(screen.queryByText("Cycle-time distribution")).toBeNull();
expect(screen.queryByText("p50")).toBeNull();
});

it("omits the sparkbar when the distribution is empty", () => {
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. Shows the shared EmptyState (#6175) when there are no
* paired gate_decision → pr_outcome samples in the window. */
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"}
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."
action={
<StatusPill status={hasSamples ? "ready" : "info"}>
{hasSamples ? `${cycleTime.sampleSize} paired PR(s)` : "no samples yet"}
</StatusPill>
}
>
<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 @@ -9,30 +10,28 @@ import {
import { formatGeneratedAt } from "@/components/site/app-panels/slop-duplicate-trend-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. Read-only; public-safe aggregate counts only. The count Stats
* always render regardless of the outcome mix, so this card stays in AnalyticsCardShell's "ready" state and
* keeps its own inner outcome-mix-vs-EmptyState toggle (#6175), matching ReversalHealthCard's shape. */
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>
<AnalyticsCardShell
title="Gate outcomes"
description={`Terminal gate dispositions from audit events over the last ${breakdown.windowDays} day(s).`}
state="ready"
action={
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-token-2xs text-muted-foreground">
generated {formatGeneratedAt(breakdown.generatedAt)}
</span>
<BoundaryBadge boundary="public" />
</div>
</div>

<div className="mt-4 grid gap-3 sm:grid-cols-3">
}
>
<div className="grid gap-3 sm:grid-cols-3">
<Stat
label="Auto-merged"
value={String(breakdown.counts.autoMerged)}
Expand Down Expand Up @@ -100,6 +99,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 @@ -8,27 +9,25 @@ 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). */
* projects at all (keeps the analytics page clean until the gate has produced eval rows) -- this early return,
* not AnalyticsCardShell's own "empty" state, is this card's documented no-data behavior (#6175). */
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,22 @@ 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. Lists reversed targets when present; EmptyState when none. The rate
* Stats always render regardless of the list, so this card stays in AnalyticsCardShell's "ready" state and
* keeps its own inner list-vs-EmptyState toggle (#6175) rather than using the shell's own "empty" state,
* which would also hide the Stats. */
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 +79,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>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -71,12 +71,15 @@ describe("SlopDuplicateTrendCard", () => {
})}
/>,
);
expect(screen.getByText("No snapshot history yet")).toBeTruthy();
expect(
screen.getByText(
/Queue-health snapshot history will appear here after signal snapshot jobs run/i,
),
).toBeTruthy();
expect(screen.queryByLabelText("Trend chart")).toBeNull();
// The header action slot (freshness pill + generated-at stamp) renders in every state (#6175).
expect(screen.getByText("fresh snapshot")).toBeTruthy();
});

it("surfaces the stale snapshot pill when data is old", () => {
Expand Down
Loading
Loading