From 366ac663b8dd8be09d6e74b0ffde8fa4abec58a7 Mon Sep 17 00:00:00 2001 From: dhgoal <153369624+dhgoal@users.noreply.github.com> Date: Sat, 11 Jul 2026 05:55:33 +0900 Subject: [PATCH] feat(ui): add finding acceptance-rate card to the analytics dashboard (#2197) Render the finding acceptance-rate signal (inline findings the contributor acted on: posted inline -> PR merged) as a single analytics card. Display slice only per the bounty: the backend acceptance computation is tracked in #1967, so the card reads an optional acceptance shape off the operator dashboard payload and degrades to a 'not yet available' empty state until it lands. Rate is banded (healthy/mixed/low) with a window-days label, reusing the shared AnalyticsCardShell + Stat/StatusPill primitives. --- .../app-panels/acceptance-rate-card.test.tsx | 51 ++++++++++++++ .../site/app-panels/acceptance-rate-card.tsx | 70 +++++++++++++++++++ .../src/routes/app.analytics.tsx | 7 ++ 3 files changed, 128 insertions(+) create mode 100644 apps/gittensory-ui/src/components/site/app-panels/acceptance-rate-card.test.tsx create mode 100644 apps/gittensory-ui/src/components/site/app-panels/acceptance-rate-card.tsx diff --git a/apps/gittensory-ui/src/components/site/app-panels/acceptance-rate-card.test.tsx b/apps/gittensory-ui/src/components/site/app-panels/acceptance-rate-card.test.tsx new file mode 100644 index 0000000000..395b08a9f6 --- /dev/null +++ b/apps/gittensory-ui/src/components/site/app-panels/acceptance-rate-card.test.tsx @@ -0,0 +1,51 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { AcceptanceRateCard } from "@/components/site/app-panels/acceptance-rate-card"; + +describe("AcceptanceRateCard", () => { + it("renders the rounded rate, plural counts, healthy band, and window label when data is present", () => { + render( + , + ); + expect(screen.getByText("75%")).toBeTruthy(); + expect(screen.getByText(/9 of 12 inline findings acted on/)).toBeTruthy(); + expect(screen.getByText("healthy")).toBeTruthy(); + expect(screen.getByText("30d window")).toBeTruthy(); + }); + + it("uses the singular 'finding' wording when exactly one finding is in the window", () => { + render(); + expect(screen.getByText("100%")).toBeTruthy(); + expect(screen.getByText(/1 of 1 inline finding acted on/)).toBeTruthy(); + }); + + it("bands a mid-range rate as 'mixed'", () => { + render(); + expect(screen.getByText("40%")).toBeTruthy(); + expect(screen.getByText("mixed")).toBeTruthy(); + }); + + it("bands a low rate as 'low'", () => { + render( + , + ); + expect(screen.getByText("10%")).toBeTruthy(); + expect(screen.getByText("low")).toBeTruthy(); + }); + + it("shows an em-dash and 'no findings' band when the window is empty (rate null)", () => { + render( + , + ); + expect(screen.getByText("—")).toBeTruthy(); + expect(screen.getByText(/0 of 0 inline findings acted on/)).toBeTruthy(); + expect(screen.getByText("no findings")).toBeTruthy(); + }); + + it("renders the 'not yet available' empty state when the acceptance field is absent", () => { + render(); + expect(screen.getByText("Not yet available")).toBeTruthy(); + expect(screen.queryByText("Acceptance rate")).toBeNull(); + }); +}); diff --git a/apps/gittensory-ui/src/components/site/app-panels/acceptance-rate-card.tsx b/apps/gittensory-ui/src/components/site/app-panels/acceptance-rate-card.tsx new file mode 100644 index 0000000000..d856227b6b --- /dev/null +++ b/apps/gittensory-ui/src/components/site/app-panels/acceptance-rate-card.tsx @@ -0,0 +1,70 @@ +import { AnalyticsCardShell } from "@/components/site/app-panels/analytics-card-shell"; +import { Stat, StatusPill, type Status } from "@/components/site/control-primitives"; + +/** Finding acceptance-rate slice (#2197): the share of inline AI-review findings the contributor acted on + * (a finding was posted inline → the PR then merged). Display-only — the backend acceptance computation is + * tracked separately in #1967, so this card assumes the shape may be absent from the dashboard payload today + * and degrades to a "not yet available" empty state until it lands, rather than assuming a value. */ +export type FindingAcceptance = { + windowDays: number; + accepted: number; + total: number; + /** accepted / total, already computed server-side; null when total === 0 so the card never divides by zero. */ + rate: number | null; +}; + +/** Quality band for the rate — higher acceptance = healthier signal; null (no findings) reads as neutral. */ +function acceptanceStatus(rate: number | null): Status { + if (rate === null) return "info"; + if (rate >= 0.6) return "ready"; + if (rate >= 0.3) return "warn"; + return "degraded"; +} + +function acceptanceBandLabel(rate: number | null): string { + if (rate === null) return "no findings"; + if (rate >= 0.6) return "healthy"; + if (rate >= 0.3) return "mixed"; + return "low"; +} + +export function AcceptanceRateCard({ acceptance }: { acceptance?: FindingAcceptance }) { + if (!acceptance) { + return ( + + ); + } + + const { windowDays, accepted, total, rate } = acceptance; + const value = rate === null ? "—" : `${Math.round(rate * 100)}%`; + + return ( + +
+ + {accepted} of {total} inline finding{total === 1 ? "" : "s"} acted on + + } + /> +
+ {acceptanceBandLabel(rate)} + {windowDays}d window +
+
+
+ ); +} diff --git a/apps/gittensory-ui/src/routes/app.analytics.tsx b/apps/gittensory-ui/src/routes/app.analytics.tsx index ca654e75a2..094868e8e6 100644 --- a/apps/gittensory-ui/src/routes/app.analytics.tsx +++ b/apps/gittensory-ui/src/routes/app.analytics.tsx @@ -16,6 +16,10 @@ import type { GateEvalReport } from "@/components/site/app-panels/gate-precision import { CycleTimeCard } from "@/components/site/app-panels/cycle-time-card"; import type { CycleTimeAggregate } from "@/components/site/app-panels/cycle-time-card-model"; import { AnalyticsCardShell } from "@/components/site/app-panels/analytics-card-shell"; +import { + AcceptanceRateCard, + type FindingAcceptance, +} from "@/components/site/app-panels/acceptance-rate-card"; import { useApiResource } from "@/lib/api/use-api-resource"; import { exportOperatorDashboardCsv } from "@/lib/csv-export"; @@ -108,6 +112,7 @@ type OperatorDashboard = { upstreamDrift?: { status?: string; openReportCount?: number } | null; gateEval?: GateEvalReport; cycleTime?: CycleTimeAggregate; + acceptance?: FindingAcceptance; }; function ProductAnalytics() { @@ -209,6 +214,8 @@ function ProductAnalytics() { /> )} + + {data.usageSummary ? (