From 10cf1f09f10d1e723bf64cbc4d8ce43f11fbcf5e Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Fri, 10 Jul 2026 04:54:17 -0700 Subject: [PATCH] feat(stats): add accuracy and reuse-rate sparklines to the homepage hero (#4447, #4448) Rides the two trend series already shipped in /v1/public/stats beside their stat tiles instead of a separate /stats page, per the hero-band placement call. Nulls (below-sample weeks) render as a genuine gap, not a fabricated zero. --- .../components/site/control-primitives.tsx | 9 ++- .../site/proof-of-power-stats-model.ts | 42 +++++++++++ .../site/proof-of-power-stats.test.tsx | 34 ++++++++- .../components/site/proof-of-power-stats.tsx | 20 +++++- .../src/components/site/sparkline.tsx | 71 +++++++++++++++++++ apps/gittensory-ui/vitest.setup.ts | 10 +++ 6 files changed, 183 insertions(+), 3 deletions(-) create mode 100644 apps/gittensory-ui/src/components/site/sparkline.tsx diff --git a/apps/gittensory-ui/src/components/site/control-primitives.tsx b/apps/gittensory-ui/src/components/site/control-primitives.tsx index faf6d01de1..2f61b308e7 100644 --- a/apps/gittensory-ui/src/components/site/control-primitives.tsx +++ b/apps/gittensory-ui/src/components/site/control-primitives.tsx @@ -165,17 +165,24 @@ export function Stat({ label, value, hint, + trend, className, }: { label: string; value: ReactNode; hint?: ReactNode; + /** Optional sparkline (or any small trend figure) rendered beside the value -- the "trend" slot from the + * stat-tile contract. Decoupled from any charting library: Stat only lays it out. */ + trend?: ReactNode; className?: string; }) { return (
{label}
-
{value}
+
+
{value}
+ {trend} +
{hint &&
{hint}
}
); diff --git a/apps/gittensory-ui/src/components/site/proof-of-power-stats-model.ts b/apps/gittensory-ui/src/components/site/proof-of-power-stats-model.ts index 96a0f2c92e..a081836b53 100644 --- a/apps/gittensory-ui/src/components/site/proof-of-power-stats-model.ts +++ b/apps/gittensory-ui/src/components/site/proof-of-power-stats-model.ts @@ -26,6 +26,21 @@ export type PublicStats = { closed: number; accuracyPct: number | null; }>; + /** Trailing weekly history of totals.accuracyPct's SAME formula (#4447). */ + accuracyTrend: Array<{ + weekStart: string; + merged: number; + closed: number; + reversed: number; + accuracyPct: number | null; + }>; + /** Trailing weekly "how often we avoid redoing AI work" trend (#4448). */ + reuseRateTrend: Array<{ + weekStart: string; + hits: number; + misses: number; + reuseRatePct: number | null; + }>; }; /** Relative "updated Ns ago" label from the payload's updatedAt (mirrors MetaStrip's freshness logic). */ @@ -55,3 +70,30 @@ export function formatTimeSaved(minutes: number): { value: number; unit: string } return { value: Math.round(minutes), unit: "min" }; } + +const WEEK_LABEL_FORMATTER = new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + timeZone: "UTC", +}); + +/** "Jun 15" from a `weekStart` (YYYY-MM-DD, always a UTC Monday). Falls back to the raw string on a malformed + * date rather than throwing or rendering "Invalid Date". */ +export function formatWeekLabel(weekStart: string): string { + const ms = Date.parse(`${weekStart}T00:00:00.000Z`); + return Number.isFinite(ms) ? WEEK_LABEL_FORMATTER.format(ms) : weekStart; +} + +/** A single trend chart's plot-ready point: a chart-agnostic {label, value} pair for a fixed X/Y encoding + * (recharts, or any other renderer). `value` is null for a week below its own MIN_SAMPLE floor -- the caller + * decides how to render a gap (recharts breaks a Line's segment at a null point by default, which is exactly + * the "insufficient data" signal a reader should see rather than a fabricated 0%). */ +export type TrendPoint = { label: string; value: number | null }; + +/** Shared shape both accuracyTrend and reuseRateTrend already satisfy -- a week label plus a nullable percent. */ +export function toTrendPoints( + weeks: ReadonlyArray, + pct: (week: T) => number | null, +): TrendPoint[] { + return weeks.map((week) => ({ label: formatWeekLabel(week.weekStart), value: pct(week) })); +} diff --git a/apps/gittensory-ui/src/components/site/proof-of-power-stats.test.tsx b/apps/gittensory-ui/src/components/site/proof-of-power-stats.test.tsx index 00f93671bf..9f930e61b8 100644 --- a/apps/gittensory-ui/src/components/site/proof-of-power-stats.test.tsx +++ b/apps/gittensory-ui/src/components/site/proof-of-power-stats.test.tsx @@ -53,6 +53,26 @@ const PAYLOAD: PublicStats = { }, { project: "JSONbored/gittensory", reviewed: 193, merged: 24, closed: 24, accuracyPct: 93.8 }, ], + accuracyTrend: [ + { weekStart: "2026-05-04", merged: 40, closed: 10, reversed: 2, accuracyPct: 96 }, + { weekStart: "2026-05-11", merged: 42, closed: 9, reversed: 1, accuracyPct: 98 }, + { weekStart: "2026-05-18", merged: 38, closed: 12, reversed: 1, accuracyPct: 98 }, + { weekStart: "2026-05-25", merged: 45, closed: 8, reversed: 0, accuracyPct: 100 }, + { weekStart: "2026-06-01", merged: 41, closed: 11, reversed: 1, accuracyPct: 98 }, + { weekStart: "2026-06-08", merged: 1, closed: 0, reversed: 0, accuracyPct: null }, + { weekStart: "2026-06-15", merged: 44, closed: 9, reversed: 1, accuracyPct: 98 }, + { weekStart: "2026-06-22", merged: 39, closed: 10, reversed: 1, accuracyPct: 98 }, + ], + reuseRateTrend: [ + { weekStart: "2026-05-04", hits: 60, misses: 40, reuseRatePct: 60 }, + { weekStart: "2026-05-11", hits: 65, misses: 35, reuseRatePct: 65 }, + { weekStart: "2026-05-18", hits: 70, misses: 30, reuseRatePct: 70 }, + { weekStart: "2026-05-25", hits: 72, misses: 28, reuseRatePct: 72 }, + { weekStart: "2026-06-01", hits: 75, misses: 25, reuseRatePct: 75 }, + { weekStart: "2026-06-08", hits: 78, misses: 22, reuseRatePct: 78 }, + { weekStart: "2026-06-15", hits: 80, misses: 20, reuseRatePct: 80 }, + { weekStart: "2026-06-22", hits: 83, misses: 17, reuseRatePct: 83 }, + ], }; function renderWithClient(ui: ReactNode) { @@ -109,7 +129,7 @@ describe("ProofOfPowerStats", () => { expect(container.firstChild).toBeNull(); }); - it("renders the four headline stats when data is live", async () => { + it("renders the five headline stats when data is live", async () => { apiFetch.mockResolvedValue({ ok: true, status: 200, durationMs: 1, data: PAYLOAD }); renderWithClient(); expect(await screen.findByText("PRs reviewed")).toBeTruthy(); @@ -120,6 +140,18 @@ describe("ProofOfPowerStats", () => { expect(screen.getByText("98.4%")).toBeTruthy(); expect(screen.getByText("33 human-reversed")).toBeTruthy(); expect(screen.getByText("1,316 closed, advised, or escalated")).toBeTruthy(); // 2708 − 1392 + // #4448: the fifth tile, its latest week's reuse rate, and its own sparkline. + expect(screen.getByText("AI work reused")).toBeTruthy(); + expect(screen.getByText("83%")).toBeTruthy(); // reuseRateTrend's last entry + expect(screen.getByText("avoided redoing prior AI work")).toBeTruthy(); + }); + + it("renders a sparkline beside accuracy and reuse-rate, each labeled by its own week count", async () => { + apiFetch.mockResolvedValue({ ok: true, status: 200, durationMs: 1, data: PAYLOAD }); + renderWithClient(); + await screen.findByText("Decision accuracy"); + const sparklines = screen.getAllByRole("img", { name: "Trend over the last 8 weeks" }); + expect(sparklines).toHaveLength(2); // accuracy + reuse-rate, both 8-week payloads }); it("settles the count-up on the real reviewed total (not stuck at 0 when rAF never fires)", async () => { diff --git a/apps/gittensory-ui/src/components/site/proof-of-power-stats.tsx b/apps/gittensory-ui/src/components/site/proof-of-power-stats.tsx index c8fe5670fb..c0cb76dcce 100644 --- a/apps/gittensory-ui/src/components/site/proof-of-power-stats.tsx +++ b/apps/gittensory-ui/src/components/site/proof-of-power-stats.tsx @@ -5,9 +5,11 @@ import { cn } from "@/lib/utils"; import { getApiOrigin } from "@/lib/api/origin"; import { apiFetch } from "@/lib/api/request"; import { Stat } from "@/components/site/control-primitives"; +import { Sparkline } from "@/components/site/sparkline"; import { formatStatsAgo, formatTimeSaved, + toTrendPoints, type PublicStats, } from "@/components/site/proof-of-power-stats-model"; @@ -95,6 +97,15 @@ export function ProofOfPowerStats({ className }: { className?: string }) { const { totals, weekly, byProject } = data; const repoCount = byProject.length; const timeSaved = formatTimeSaved(totals.minutesSaved); + // #4447/#4448: 8-week sparklines riding beside the two tiles that have a weekly trend to show. The other + // tiles (PRs reviewed, filtered %, time saved) have no persisted weekly series, only a lifetime total plus a + // single "this week" delta -- nothing for a sparkline to plot yet. + const accuracySparkline = toTrendPoints(data.accuracyTrend, (week) => week.accuracyPct); + const reuseRateSparkline = toTrendPoints(data.reuseRateTrend, (week) => week.reuseRatePct); + const latestReuseRatePct = + data.reuseRateTrend.length > 0 + ? data.reuseRateTrend[data.reuseRateTrend.length - 1]!.reuseRatePct + : null; return (
-
+
} @@ -135,6 +146,13 @@ export function ProofOfPowerStats({ className }: { className?: string }) { ? `${intFmt.format(totals.reversed)} human-reversed` : "reversal-grounded" } + trend={} + /> + } />
diff --git a/apps/gittensory-ui/src/components/site/sparkline.tsx b/apps/gittensory-ui/src/components/site/sparkline.tsx new file mode 100644 index 0000000000..d36004a4f0 --- /dev/null +++ b/apps/gittensory-ui/src/components/site/sparkline.tsx @@ -0,0 +1,71 @@ +import { Line, LineChart, ResponsiveContainer } from "recharts"; + +import type { TrendPoint } from "./proof-of-power-stats-model"; + +// Stat-tile sparkline (dataviz skill's "Figures" contract: "trend — optional; 12-point sparkline in the +// de-emphasis hue, current period in the accent"). No axes, gridlines, or tooltip -- a sparkline is the +// glanceable figure that rides beside a stat tile's own value, not a standalone chart; the exact numbers stay +// reachable from the value it sits next to and (for these two metrics) the public API response. A gap in the +// line (a null point, e.g. a week below its own minimum-sample floor) is the correct rendering for "not enough +// data yet" -- recharts breaks the segment there rather than drawing a fabricated straight line through it. + +const SPARKLINE_WIDTH = 64; +const SPARKLINE_HEIGHT = 28; + +export function Sparkline({ + points, + color, + className, +}: { + points: TrendPoint[]; + color: string; + className?: string; +}) { + const hasAnyValue = points.some((point) => point.value !== null); + if (!hasAnyValue) return null; + const data = points.map((point, index) => ({ index, value: point.value })); + const lastValueIndex = points.reduce( + (last, point, index) => (point.value !== null ? index : last), + -1, + ); + return ( +
+ + + { + const { index, cx, cy, key } = props; + if (index !== lastValueIndex || cx === undefined || cy === undefined) { + return ; + } + return ( + + ); + }} + isAnimationActive={false} + connectNulls={false} + /> + + +
+ ); +} diff --git a/apps/gittensory-ui/vitest.setup.ts b/apps/gittensory-ui/vitest.setup.ts index 287cafc566..3b6e24d082 100644 --- a/apps/gittensory-ui/vitest.setup.ts +++ b/apps/gittensory-ui/vitest.setup.ts @@ -5,3 +5,13 @@ import { afterEach } from "vitest"; afterEach(() => { cleanup(); }); + +// jsdom has no ResizeObserver -- recharts' ResponsiveContainer (used by any chart/sparkline) needs one to +// mount at all. A no-op stub is the standard fix: it never actually resizes in a test DOM, and no test here +// asserts on a resize-driven re-render, only on the rendered markup. +class ResizeObserverStub { + observe(): void {} + unobserve(): void {} + disconnect(): void {} +} +globalThis.ResizeObserver ??= ResizeObserverStub as unknown as typeof ResizeObserver;