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
Expand Up @@ -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 (
<div className={cn("rounded-token border border-border p-4", className)}>
<div className="text-token-xs text-muted-foreground">{label}</div>
<div className="mt-1 text-token-xl font-medium tracking-tight text-foreground">{value}</div>
<div className="mt-1 flex items-end justify-between gap-2">
<div className="text-token-xl font-medium tracking-tight text-foreground">{value}</div>
{trend}
</div>
{hint && <div className="mt-1 text-token-xs text-muted-foreground">{hint}</div>}
</div>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down Expand Up @@ -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<T extends { weekStart: string }>(
weeks: ReadonlyArray<T>,
pct: (week: T) => number | null,
): TrendPoint[] {
return weeks.map((week) => ({ label: formatWeekLabel(week.weekStart), value: pct(week) }));
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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(<ProofOfPowerStats />);
expect(await screen.findByText("PRs reviewed")).toBeTruthy();
Expand All @@ -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(<ProofOfPowerStats />);
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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 (
<section
className={cn("mx-auto w-full max-w-6xl px-4 pb-2 sm:px-6", className)}
Expand All @@ -107,7 +118,7 @@ export function ProofOfPowerStats({ className }: { className?: string }) {
updated {formatStatsAgo(data.updatedAt, now)}
</span>
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
<Stat
label="PRs reviewed"
value={<Num value={totals.reviewed} />}
Expand Down Expand Up @@ -135,6 +146,13 @@ export function ProofOfPowerStats({ className }: { className?: string }) {
? `${intFmt.format(totals.reversed)} human-reversed`
: "reversal-grounded"
}
trend={<Sparkline points={accuracySparkline} color="var(--chart-1)" />}
/>
<Stat
label="AI work reused"
value={latestReuseRatePct == null ? "—" : `${latestReuseRatePct}%`}
hint="avoided redoing prior AI work"
trend={<Sparkline points={reuseRateSparkline} color="var(--chart-2)" />}
/>
</div>
</section>
Expand Down
71 changes: 71 additions & 0 deletions apps/gittensory-ui/src/components/site/sparkline.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
className={className}
style={{ width: SPARKLINE_WIDTH, height: SPARKLINE_HEIGHT }}
role="img"
aria-label={`Trend over the last ${points.length} weeks`}
>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={{ top: 2, right: 3, bottom: 2, left: 3 }}>
<Line
type="monotone"
dataKey="value"
stroke={color}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
dot={(props: { index?: number; cx?: number; cy?: number; key?: string }) => {
const { index, cx, cy, key } = props;
if (index !== lastValueIndex || cx === undefined || cy === undefined) {
return <g key={key} />;
}
return (
<circle
key={key}
cx={cx}
cy={cy}
r={2.5}
fill={color}
stroke="var(--background)"
strokeWidth={1}
/>
);
}}
isAnimationActive={false}
connectNulls={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
);
}
10 changes: 10 additions & 0 deletions apps/gittensory-ui/vitest.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Loading