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
@@ -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(
<FindingsBreakdownCard
findings={{
windowDays: 30,
categories: [
{ category: "security", total: 5, bySeverity: { blocker: 2, warning: 3 } },
{ category: "style", total: 4, bySeverity: { advisory: 1, nit: 3 } },
],
}}
/>,
);
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(
<FindingsBreakdownCard
findings={{
windowDays: 7,
categories: [{ category: "correctness", total: 1, bySeverity: { blocker: 1 } }],
}}
/>,
);
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(<FindingsBreakdownCard findings={{ windowDays: 14, categories: [] }} />);
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(<FindingsBreakdownCard />);
expect(screen.getByText("Not yet available")).toBeTruthy();
expect(screen.queryByText("security")).toBeNull();
});
});
Original file line number Diff line number Diff line change
@@ -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<Record<FindingSeverityTier, number>>;
};

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<FindingSeverityTier, string> = {
blocker: "bg-danger",
warning: "bg-warning",
advisory: "bg-mint",
nit: "bg-muted-foreground",
};

const SEVERITY_TEXT: Record<FindingSeverityTier, string> = {
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 (
<div className="space-y-1.5">
<div className="flex items-center justify-between gap-3 text-token-sm">
<span className="font-medium text-foreground">{row.category}</span>
<span className="font-mono text-token-xs text-muted-foreground">{row.total}</span>
</div>
<div className="flex h-2 overflow-hidden rounded-full bg-border" aria-hidden>
{segments.map((tier) => (
<div
key={tier}
className={cn("h-full", SEVERITY_BAR[tier])}
style={{
width: `${row.total > 0 ? ((row.bySeverity[tier] ?? 0) / row.total) * 100 : 0}%`,
}}
/>
))}
</div>
<div className="flex flex-wrap gap-x-3 gap-y-1 font-mono text-token-2xs uppercase tracking-wider">
{segments.map((tier) => (
<span key={tier} className={SEVERITY_TEXT[tier]}>
{tier} {row.bySeverity[tier]}
</span>
))}
</div>
</div>
);
}

export function FindingsBreakdownCard({ findings }: { findings?: FindingsBreakdown }) {
const hasData = findings != null && findings.categories.length > 0;

if (!hasData) {
return (
<AnalyticsCardShell
title="Findings by category"
description="AI-review findings grouped by category and severity."
state="empty"
emptyTitle={findings ? "No findings in window" : "Not yet available"}
emptyHint={
findings
? "No AI-review findings were recorded in the analytics window."
: "The category breakdown appears once the findings feed is wired into the dashboard payload."
}
/>
);
}

return (
<AnalyticsCardShell
title="Findings by category"
description="AI-review findings grouped by category and severity."
state="ready"
>
<div className="flex items-center justify-end">
<StatusPill status="info">{findings.windowDays}d window</StatusPill>
</div>
<div className="mt-3 space-y-4">
{findings.categories.map((row) => (
<CategoryRow key={row.category} row={row} />
))}
</div>
</AnalyticsCardShell>
);
}
7 changes: 7 additions & 0 deletions apps/gittensory-ui/src/routes/app.analytics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@
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";

Expand Down Expand Up @@ -113,9 +117,10 @@
gateEval?: GateEvalReport;
cycleTime?: CycleTimeAggregate;
acceptance?: FindingAcceptance;
findingsBreakdown?: FindingsBreakdown;
};

function ProductAnalytics() {

Check warning on line 123 in apps/gittensory-ui/src/routes/app.analytics.tsx

View workflow job for this annotation

GitHub Actions / validate-code

Fast refresh only works when a file only exports components. Move your component(s) to a separate file. If all exports are HOCs, add them to the `extraHOCs` option
const dashboard = useApiResource<OperatorDashboard>(
"/v1/app/operator-dashboard",
"Product analytics",
Expand Down Expand Up @@ -216,6 +221,8 @@

<AcceptanceRateCard acceptance={data.acceptance} />

<FindingsBreakdownCard findings={data.findingsBreakdown} />

{data.usageSummary ? (
<ProductUsageBreakdownPanel
byEvent={data.usageSummary.byEvent}
Expand Down
Loading