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..2dc5881f9 --- /dev/null +++ b/apps/gittensory-ui/src/components/site/app-panels/slop-band-calibration-card.test.tsx @@ -0,0 +1,76 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { SlopBandCalibrationCard } from "@/components/site/app-panels/slop-band-calibration-card"; + +describe("SlopBandCalibrationCard", () => { + it("renders a row per band with merge rate + counts and the discrimination verdict when all bands have data", () => { + render( + , + ); + expect(screen.getByText("clean")).toBeTruthy(); + expect(screen.getByText("high")).toBeTruthy(); + expect(screen.getByText("90% merged")).toBeTruthy(); + expect(screen.getByText("predictive")).toBeTruthy(); + expect(screen.getByText(/40 resolved · 60% merged overall/)).toBeTruthy(); + }); + + it("shows the '— no samples' state for an empty band and marks insufficient data", () => { + render( + , + ); + expect(screen.getByText("— no samples")).toBeTruthy(); + expect(screen.getByText("insufficient data")).toBeTruthy(); + }); + + it("flags an inverted (non-predictive) score", () => { + render( + , + ); + expect(screen.getByText("inverted")).toBeTruthy(); + }); + + it("shows the 'no resolved PRs' empty state when the calibration has no resolved data", () => { + render( + , + ); + expect(screen.getByText("No resolved PRs with a slop band")).toBeTruthy(); + expect(screen.queryByText("predictive")).toBeNull(); + }); + + it("shows the 'not yet available' empty state when the calibration field is absent", () => { + render(); + expect(screen.getByText("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..bb7bfbed3 --- /dev/null +++ b/apps/gittensory-ui/src/components/site/app-panels/slop-band-calibration-card.tsx @@ -0,0 +1,106 @@ +import { AnalyticsCardShell } from "@/components/site/app-panels/analytics-card-shell"; +import { StatusPill, type Status } from "@/components/site/control-primitives"; +import { cn } from "@/lib/utils"; + +/** Slop-band calibration (#2196): per-band merge/close rates over resolved PRs that carry a persisted slop band — + * is the deterministic slop score predictive (do higher-slop bands merge less)? Display slice over the + * operator-dashboard's `slopCalibration` payload. Bands only, never raw scores (public/private boundary). */ +export type SlopBand = "clean" | "low" | "elevated" | "high"; + +export type SlopBandCalibration = { + band: SlopBand; + sampleSize: number; + merged: number; + closed: number; + mergeRate: number; +}; + +export type SlopOutcomeCalibration = { + totalResolved: number; + bands: SlopBandCalibration[]; + overallMergeRate: number | null; + discriminates: boolean | null; +}; + +/** clean → high reads low-to-high severity; the bar color tracks band severity. */ +const BAND_BAR: Record = { + clean: "bg-success", + low: "bg-mint", + elevated: "bg-warning", + high: "bg-danger", +}; + +function discriminationPill(discriminates: boolean | null): { status: Status; label: string } { + if (discriminates === true) return { status: "ready", label: "predictive" }; + if (discriminates === false) return { status: "degraded", label: "inverted" }; + return { status: "info", label: "insufficient data" }; +} + +function BandRow({ row }: { row: SlopBandCalibration }) { + const hasSamples = row.sampleSize > 0; + const pct = hasSamples ? Math.round(row.mergeRate * 100) : null; + return ( +
+
+ {row.band} + + {pct === null ? "— no samples" : `${pct}% merged`} + +
+
+ {hasSamples ? ( +
+ ) : null} +
+
+ {row.sampleSize} resolved · {row.merged} merged · {row.closed} closed +
+
+ ); +} + +export function SlopBandCalibrationCard({ calibration }: { calibration?: SlopOutcomeCalibration }) { + const hasData = + calibration != null && calibration.totalResolved > 0 && calibration.bands.length > 0; + + if (!hasData) { + return ( + + ); + } + + const pill = discriminationPill(calibration.discriminates); + const overall = + calibration.overallMergeRate === null ? null : Math.round(calibration.overallMergeRate * 100); + + return ( + +
+ + {calibration.totalResolved} resolved + {overall === null ? "" : ` · ${overall}% merged overall`} + + {pill.label} +
+
+ {calibration.bands.map((row) => ( + + ))} +
+
+ ); +} diff --git a/apps/gittensory-ui/src/routes/app.analytics.tsx b/apps/gittensory-ui/src/routes/app.analytics.tsx index 7a26005d6..845bdd8fc 100644 --- a/apps/gittensory-ui/src/routes/app.analytics.tsx +++ b/apps/gittensory-ui/src/routes/app.analytics.tsx @@ -28,6 +28,10 @@ import { FindingsBreakdownCard, type FindingsBreakdown, } from "@/components/site/app-panels/findings-breakdown-card"; +import { + SlopBandCalibrationCard, + type SlopOutcomeCalibration, +} from "@/components/site/app-panels/slop-band-calibration-card"; import { useApiResource } from "@/lib/api/use-api-resource"; import { exportOperatorDashboardCsv } from "@/lib/csv-export"; @@ -124,6 +128,7 @@ type OperatorDashboard = { agentHealth?: ReversalHealth; acceptance?: FindingAcceptance; findingsBreakdown?: FindingsBreakdown; + slopCalibration?: SlopOutcomeCalibration; }; function ProductAnalytics() { @@ -232,6 +237,8 @@ function ProductAnalytics() { + + {data.usageSummary ? ( buildSlopOutcomeCalibration([])), ]); const weeklyValueReport = buildWeeklyValueReport({ generatedAt: nowIso(), @@ -224,6 +235,7 @@ export async function buildOperatorDashboardPayload(env: Env): Promise { recentAutoActions: 0, reversedTargets: [], }); + // #2196: org-wide slop-band calibration fails safe to an empty calibration when no resolved PR carries a band. + expect(payload.slopCalibration).toMatchObject({ + totalResolved: 0, + overallMergeRate: null, + discriminates: null, + }); // Empty fleet → instanceCount 0, null precision card ("—"), no-outlier delta. expect(payload.fleetMetrics.instanceCount).toBe(0); expect(payload.metrics).toEqual(