diff --git a/apps/gittensory-ui/src/components/site/app-panels/slop-band-calibration-card-model.ts b/apps/gittensory-ui/src/components/site/app-panels/slop-band-calibration-card-model.ts new file mode 100644 index 000000000..ca86fb3a0 --- /dev/null +++ b/apps/gittensory-ui/src/components/site/app-panels/slop-band-calibration-card-model.ts @@ -0,0 +1,52 @@ +// Slop-band calibration analytics card model (#2196). UI-only display slice: the card consumes a slop-band +// calibration shape assumed present on the operator-dashboard payload (predicted slop band vs. realized +// merge/close outcome, from the stats feed — src/review/stats.ts). Types + the pure ordering/format helpers live +// here (not in the .tsx) so the component file exports only components (react-refresh/only-export-components). +// Public/private boundary: bands only — never a raw credibility score. + +import type { Status } from "@/components/site/control-primitives"; + +/** The predicted slop bands, cleanest → highest, in display order. */ +export type SlopBand = "clean" | "low" | "elevated" | "high"; + +/** One band's calibration row: how many PRs the gate predicted in this band and how they actually resolved. */ +export interface SlopBandCalibrationRow { + band: SlopBand; + /** PRs the gate predicted in this band over the window. */ + predictedCount: number; + /** Realized merge rate (0..1): share of this band's PRs that ended up merged — the calibration signal. */ + realizedMergeRate: number; + /** Per-bucket outcome counts for the MiniSparkbar. */ + distribution: number[]; +} + +/** The slop-band calibration slice delivered on the operator-dashboard payload. Public-safe bands only. */ +export interface SlopBandCalibrationReport { + rows: SlopBandCalibrationRow[]; + /** Rolling measurement window, in days. */ + windowDays: number; +} + +const BAND_ORDER: readonly SlopBand[] = ["clean", "low", "elevated", "high"]; + +const BAND_TONE: Record = { + clean: "ok", + low: "info", + elevated: "warn", + high: "blocked", +}; + +/** Order rows cleanest → highest band, ignoring the payload's arrival order. Pure (returns a new array). */ +export function orderSlopBands(rows: readonly SlopBandCalibrationRow[]): SlopBandCalibrationRow[] { + return [...rows].sort((a, b) => BAND_ORDER.indexOf(a.band) - BAND_ORDER.indexOf(b.band)); +} + +/** StatusPill tone for a band — mirrors the slop-band tone vocabulary used across the maintainer dashboard. */ +export function toneForSlopBand(band: SlopBand): Status { + return BAND_TONE[band]; +} + +/** Format a 0..1 merge rate as a whole-number percentage. */ +export function formatMergeRate(rate: number): string { + return `${Math.round(rate * 100)}%`; +} diff --git a/apps/gittensory-ui/src/components/site/app-panels/slop-band-calibration-card.test.tsx b/apps/gittensory-ui/src/components/site/app-panels/slop-band-calibration-card.test.tsx new file mode 100644 index 000000000..db1012938 --- /dev/null +++ b/apps/gittensory-ui/src/components/site/app-panels/slop-band-calibration-card.test.tsx @@ -0,0 +1,60 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { SlopBandCalibrationCard } from "@/components/site/app-panels/slop-band-calibration-card"; +import { + formatMergeRate, + orderSlopBands, + toneForSlopBand, + type SlopBandCalibrationRow, +} from "@/components/site/app-panels/slop-band-calibration-card-model"; + +const rows: SlopBandCalibrationRow[] = [ + { band: "high", predictedCount: 4, realizedMergeRate: 0.1, distribution: [1, 1, 2] }, + { band: "clean", predictedCount: 40, realizedMergeRate: 0.95, distribution: [10, 12, 18] }, + { band: "elevated", predictedCount: 9, realizedMergeRate: 0.45, distribution: [3, 3, 3] }, + { band: "low", predictedCount: 22, realizedMergeRate: 0.8, distribution: [7, 8, 7] }, +]; + +describe("slop-band model", () => { + it("orders bands cleanest → highest regardless of the payload's arrival order", () => { + expect(orderSlopBands(rows).map((r) => r.band)).toEqual(["clean", "low", "elevated", "high"]); + }); + + it("maps bands to tones and formats the merge rate as a percentage", () => { + expect(toneForSlopBand("clean")).toBe("ok"); + expect(toneForSlopBand("high")).toBe("blocked"); + expect(formatMergeRate(0.95)).toBe("95%"); + }); +}); + +describe("SlopBandCalibrationCard", () => { + it("renders a row per band with predicted counts and realized rates (all-bands-present arm)", () => { + render(); + expect(screen.getByText("Slop-band calibration")).toBeTruthy(); + expect(screen.getByText("30-day window")).toBeTruthy(); + expect(screen.getByText("clean")).toBeTruthy(); + expect(screen.getByText("high")).toBeTruthy(); + expect(screen.getByText("95%")).toBeTruthy(); + expect(screen.getByText("40")).toBeTruthy(); + }); + + it("renders a band with zero predicted PRs (one-empty-band case)", () => { + render( + , + ); + expect(screen.getByText("high")).toBeTruthy(); + expect(screen.getByText("0")).toBeTruthy(); + expect(screen.getByText("7-day window")).toBeTruthy(); + }); + + it("renders a graceful EmptyState when there is no data (no-data arm)", () => { + render(); + expect(screen.getByText("Slop-band calibration not yet available")).toBeTruthy(); + }); +}); diff --git a/apps/gittensory-ui/src/components/site/app-panels/slop-band-calibration-card.tsx b/apps/gittensory-ui/src/components/site/app-panels/slop-band-calibration-card.tsx new file mode 100644 index 000000000..3a7e9ca52 --- /dev/null +++ b/apps/gittensory-ui/src/components/site/app-panels/slop-band-calibration-card.tsx @@ -0,0 +1,60 @@ +import { MiniSparkbar, Stat, StatusPill } from "@/components/site/control-primitives"; +import { EmptyState } from "@/components/site/state-views"; + +import { + formatMergeRate, + orderSlopBands, + toneForSlopBand, + type SlopBandCalibrationReport, +} from "./slop-band-calibration-card-model"; + +/** Self-host analytics card (#2196): slop-band calibration — for each predicted slop band, how many PRs the gate + * put there and how they actually resolved (realized merge rate), so a maintainer can see whether the slop + * predictor is well-calibrated (cleaner bands should merge more, higher bands less). Public-safe bands only — no + * raw credibility scores. UI-only display slice; the calibration shape is assumed present on the operator- + * dashboard payload, so absence renders a graceful "not yet available" EmptyState. */ +export function SlopBandCalibrationCard({ report }: { report?: SlopBandCalibrationReport }) { + if (!report || report.rows.length === 0) { + return ( + + ); + } + const rows = orderSlopBands(report.rows); + return ( +
+
+
+

Slop-band calibration

+

+ Predicted slop band vs. realized merge outcome. Public-safe bands only — no raw scores. +

+
+ {`${report.windowDays}-day window`} +
+
+ {rows.map((row) => ( +
+ {row.band} + PRs in band} + /> + of this band merged} + /> + +
+ ))} +
+
+ ); +} diff --git a/apps/gittensory-ui/src/routes/app.analytics.tsx b/apps/gittensory-ui/src/routes/app.analytics.tsx index 7a26005d6..c2d71172a 100644 --- a/apps/gittensory-ui/src/routes/app.analytics.tsx +++ b/apps/gittensory-ui/src/routes/app.analytics.tsx @@ -28,6 +28,8 @@ import { FindingsBreakdownCard, type FindingsBreakdown, } from "@/components/site/app-panels/findings-breakdown-card"; +import { SlopBandCalibrationCard } from "@/components/site/app-panels/slop-band-calibration-card"; +import type { SlopBandCalibrationReport } from "@/components/site/app-panels/slop-band-calibration-card-model"; import { useApiResource } from "@/lib/api/use-api-resource"; import { exportOperatorDashboardCsv } from "@/lib/csv-export"; @@ -124,6 +126,7 @@ type OperatorDashboard = { agentHealth?: ReversalHealth; acceptance?: FindingAcceptance; findingsBreakdown?: FindingsBreakdown; + slopBandCalibration?: SlopBandCalibrationReport; }; function ProductAnalytics() { @@ -232,6 +235,10 @@ function ProductAnalytics() { + {data.slopBandCalibration ? ( + + ) : null} + {data.usageSummary ? (