diff --git a/apps/gittensory-ui/src/components/site/app-panels/findings-breakdown-card.test.tsx b/apps/gittensory-ui/src/components/site/app-panels/findings-breakdown-card.test.tsx new file mode 100644 index 0000000000..7a3cbf9cc7 --- /dev/null +++ b/apps/gittensory-ui/src/components/site/app-panels/findings-breakdown-card.test.tsx @@ -0,0 +1,53 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { FindingsBreakdownCard } from "@/components/site/app-panels/findings-breakdown-card"; + +describe("FindingsBreakdownCard", () => { + it("renders a row per category with totals, severity counts, and the window label when populated", () => { + render( + , + ); + expect(screen.getByText("security")).toBeTruthy(); + expect(screen.getByText("style")).toBeTruthy(); + expect(screen.getByText(/blocker 2/)).toBeTruthy(); + expect(screen.getByText(/warning 3/)).toBeTruthy(); + expect(screen.getByText(/nit 3/)).toBeTruthy(); + expect(screen.getByText("30d window")).toBeTruthy(); + }); + + it("renders a single-category breakdown", () => { + render( + , + ); + expect(screen.getByText("correctness")).toBeTruthy(); + expect(screen.getByText(/blocker 1/)).toBeTruthy(); + // A severity with zero count is omitted from the row. + expect(screen.queryByText(/nit/)).toBeNull(); + }); + + it("shows the 'no findings in window' empty state when the breakdown is present but has no categories", () => { + render(); + expect(screen.getByText("No findings in window")).toBeTruthy(); + expect(screen.queryByText("30d window")).toBeNull(); + }); + + it("shows the 'not yet available' empty state when the findings field is absent", () => { + render(); + expect(screen.getByText("Not yet available")).toBeTruthy(); + expect(screen.queryByText("security")).toBeNull(); + }); +}); diff --git a/apps/gittensory-ui/src/components/site/app-panels/findings-breakdown-card.tsx b/apps/gittensory-ui/src/components/site/app-panels/findings-breakdown-card.tsx new file mode 100644 index 0000000000..7d53bf304c --- /dev/null +++ b/apps/gittensory-ui/src/components/site/app-panels/findings-breakdown-card.tsx @@ -0,0 +1,104 @@ +import { AnalyticsCardShell } from "@/components/site/app-panels/analytics-card-shell"; +import { StatusPill } from "@/components/site/control-primitives"; +import { cn } from "@/lib/utils"; + +/** Findings-by-category/severity breakdown (#2195): AI-review findings grouped by category, each split by + * severity tier. Display slice — it reads an optional `findings` breakdown off the operator-dashboard payload + * and shows "no findings in window" when the window is empty, or "not yet available" until the backend + * aggregation is wired, so it ships safely ahead of the feed and lights up automatically once it lands. */ +export type FindingSeverityTier = "blocker" | "warning" | "advisory" | "nit"; + +export type FindingsCategoryBreakdown = { + category: string; + total: number; + bySeverity: Partial>; +}; + +export type FindingsBreakdown = { + windowDays: number; + categories: FindingsCategoryBreakdown[]; +}; + +/** Highest-severity first so a category's bar reads blocker → nit left-to-right. */ +const SEVERITY_ORDER: FindingSeverityTier[] = ["blocker", "warning", "advisory", "nit"]; + +const SEVERITY_BAR: Record = { + blocker: "bg-danger", + warning: "bg-warning", + advisory: "bg-mint", + nit: "bg-muted-foreground", +}; + +const SEVERITY_TEXT: Record = { + blocker: "text-danger", + warning: "text-warning", + advisory: "text-mint", + nit: "text-muted-foreground", +}; + +function CategoryRow({ row }: { row: FindingsCategoryBreakdown }) { + const segments = SEVERITY_ORDER.filter((tier) => (row.bySeverity[tier] ?? 0) > 0); + return ( +
+
+ {row.category} + {row.total} +
+
+ {segments.map((tier) => ( +
0 ? ((row.bySeverity[tier] ?? 0) / row.total) * 100 : 0}%`, + }} + /> + ))} +
+
+ {segments.map((tier) => ( + + {tier} {row.bySeverity[tier]} + + ))} +
+
+ ); +} + +export function FindingsBreakdownCard({ findings }: { findings?: FindingsBreakdown }) { + const hasData = findings != null && findings.categories.length > 0; + + if (!hasData) { + return ( + + ); + } + + return ( + +
+ {findings.windowDays}d window +
+
+ {findings.categories.map((row) => ( + + ))} +
+
+ ); +} diff --git a/apps/gittensory-ui/src/routes/app.analytics.tsx b/apps/gittensory-ui/src/routes/app.analytics.tsx index 094868e8e6..ef7c60d014 100644 --- a/apps/gittensory-ui/src/routes/app.analytics.tsx +++ b/apps/gittensory-ui/src/routes/app.analytics.tsx @@ -20,6 +20,10 @@ import { AcceptanceRateCard, type FindingAcceptance, } from "@/components/site/app-panels/acceptance-rate-card"; +import { + FindingsBreakdownCard, + type FindingsBreakdown, +} from "@/components/site/app-panels/findings-breakdown-card"; import { useApiResource } from "@/lib/api/use-api-resource"; import { exportOperatorDashboardCsv } from "@/lib/csv-export"; @@ -113,6 +117,7 @@ type OperatorDashboard = { gateEval?: GateEvalReport; cycleTime?: CycleTimeAggregate; acceptance?: FindingAcceptance; + findingsBreakdown?: FindingsBreakdown; }; function ProductAnalytics() { @@ -216,6 +221,8 @@ function ProductAnalytics() { + + {data.usageSummary ? (