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,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(
<AcceptanceRateCard acceptance={{ windowDays: 30, accepted: 9, total: 12, rate: 0.75 }} />,
);
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(<AcceptanceRateCard acceptance={{ windowDays: 14, accepted: 1, total: 1, rate: 1 }} />);
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(<AcceptanceRateCard acceptance={{ windowDays: 7, accepted: 2, total: 5, rate: 0.4 }} />);
expect(screen.getByText("40%")).toBeTruthy();
expect(screen.getByText("mixed")).toBeTruthy();
});

it("bands a low rate as 'low'", () => {
render(
<AcceptanceRateCard acceptance={{ windowDays: 7, accepted: 1, total: 10, rate: 0.1 }} />,
);
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(
<AcceptanceRateCard acceptance={{ windowDays: 7, accepted: 0, total: 0, rate: null }} />,
);
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(<AcceptanceRateCard />);
expect(screen.getByText("Not yet available")).toBeTruthy();
expect(screen.queryByText("Acceptance rate")).toBeNull();
});
});
Original file line number Diff line number Diff line change
@@ -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 (
<AnalyticsCardShell
title="Finding acceptance rate"
description="Inline findings the contributor acted on (posted inline → PR merged)."
state="empty"
emptyTitle="Not yet available"
emptyHint="Acceptance tracking appears once inline findings are posted and their PRs resolve in the analytics window."
/>
);
}

const { windowDays, accepted, total, rate } = acceptance;
const value = rate === null ? "—" : `${Math.round(rate * 100)}%`;

return (
<AnalyticsCardShell
title="Finding acceptance rate"
description="Inline findings the contributor acted on (posted inline → PR merged)."
state="ready"
>
<div className="flex flex-wrap items-center justify-between gap-4">
<Stat
label="Acceptance rate"
value={value}
hint={
<span className="text-muted-foreground">
{accepted} of {total} inline finding{total === 1 ? "" : "s"} acted on
</span>
}
/>
<div className="flex items-center gap-2">
<StatusPill status={acceptanceStatus(rate)}>{acceptanceBandLabel(rate)}</StatusPill>
<StatusPill status="info">{windowDays}d window</StatusPill>
</div>
</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 @@ -16,6 +16,10 @@
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";

Expand Down Expand Up @@ -108,9 +112,10 @@
upstreamDrift?: { status?: string; openReportCount?: number } | null;
gateEval?: GateEvalReport;
cycleTime?: CycleTimeAggregate;
acceptance?: FindingAcceptance;
};

function ProductAnalytics() {

Check warning on line 118 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 @@ -209,6 +214,8 @@
/>
)}

<AcceptanceRateCard acceptance={data.acceptance} />

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