+
}
@@ -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;