diff --git a/src/web/frontend/src/components/ProductivityPanel.tsx b/src/web/frontend/src/components/ProductivityPanel.tsx
new file mode 100644
index 0000000..35386b8
--- /dev/null
+++ b/src/web/frontend/src/components/ProductivityPanel.tsx
@@ -0,0 +1,194 @@
+import React, { useState, useEffect, useCallback } from 'react';
+
+interface DailyCount {
+ date: string;
+ count: number;
+}
+
+interface WorkerStat {
+ id: string;
+ beadsCompleted: number;
+ beadsPerHour: number;
+}
+
+interface ProductivityData {
+ daily: DailyCount[];
+ workers: WorkerStat[];
+}
+
+interface ProductivityPanelProps {
+ visible: boolean;
+ onClose: () => void;
+}
+
+// BarChart: pure SVG bar chart for daily throughput
+const BarChart: React.FC<{ data: DailyCount[]; days?: number }> = ({ data, days = 14 }) => {
+ const slice = data.slice(-days);
+ const max = Math.max(...slice.map((d) => d.count), 1);
+ const width = 600;
+ const height = 120;
+ const barW = Math.floor((width / slice.length) * 0.7);
+ const gap = Math.floor((width / slice.length) * 0.3);
+ const colW = barW + gap;
+
+ return (
+
+
+
+ );
+};
+
+const ProductivityPanel: React.FC
= ({ visible, onClose }) => {
+ const [data, setData] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const fetchData = useCallback(async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const res = await fetch('/api/productivity');
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ setData(await res.json());
+ } catch (err) {
+ setError(String(err));
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ if (visible) fetchData();
+ }, [visible, fetchData]);
+
+ if (!visible) return null;
+
+ const totalToday = data?.daily[data.daily.length - 1]?.count ?? 0;
+ const total30d = data?.daily.reduce((s, d) => s + d.count, 0) ?? 0;
+
+ return (
+
+
+
+ Productivity
+ {data && (
+
+ {totalToday} today · {total30d} last 30d
+
+ )}
+
+
+
+
+
+
+
+ {error &&
{error}
}
+
+ {data && (
+
+
+
Daily Throughput (last 14 days)
+
+ {data.daily.every((d) => d.count === 0) ? (
+
No bead completions recorded yet.
+ ) : (
+
+ )}
+
+
+
+
+
Worker Leaderboard
+
+ {data.workers.filter((w) => w.beadsCompleted > 0).length === 0 ? (
+
No completions recorded yet.
+ ) : (
+
+
+
+ | # |
+ Worker |
+ Beads |
+ Beads/hr |
+
+
+
+ {data.workers
+ .filter((w) => w.beadsCompleted > 0)
+ .map((w, i) => (
+
+ | {i + 1} |
+ {w.id} |
+ {w.beadsCompleted} |
+ {w.beadsPerHour} |
+
+ ))}
+
+
+ )}
+
+
+
+ )}
+
+ );
+};
+
+export default ProductivityPanel;
diff --git a/src/web/frontend/src/index.css b/src/web/frontend/src/index.css
index 5d1bf33..d35d587 100644
--- a/src/web/frontend/src/index.css
+++ b/src/web/frontend/src/index.css
@@ -8814,3 +8814,60 @@ body {
font-style: italic;
margin-top: 0.25rem;
}
+
+/* ============================================
+ Productivity Panel
+ ============================================ */
+
+.productivity-panel {
+ width: 700px;
+}
+
+.productivity-chart-wrap {
+ width: 100%;
+ padding: 0.5rem 0;
+}
+
+.productivity-leaderboard {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.85rem;
+}
+
+.productivity-leaderboard th {
+ text-align: left;
+ padding: 0.4rem 0.6rem;
+ color: var(--text-secondary);
+ border-bottom: 1px solid var(--border-color);
+ font-weight: 600;
+}
+
+.productivity-leaderboard td {
+ padding: 0.35rem 0.6rem;
+ border-bottom: 1px solid var(--border-color);
+}
+
+.productivity-leaderboard tbody tr:hover {
+ background: var(--bg-hover, rgba(255,255,255,0.04));
+}
+
+.productivity-rank {
+ color: var(--text-secondary);
+ width: 2rem;
+}
+
+.productivity-worker-id {
+ font-family: var(--mono-font, monospace);
+ font-size: 0.8rem;
+}
+
+.productivity-count {
+ font-weight: 600;
+ text-align: right;
+ padding-right: 1.5rem;
+}
+
+.productivity-rate {
+ color: var(--text-secondary);
+ text-align: right;
+}
diff --git a/src/web/server.ts b/src/web/server.ts
index cda00c2..e38229c 100644
--- a/src/web/server.ts
+++ b/src/web/server.ts
@@ -1096,6 +1096,49 @@ export function createWebServer(options: WebServerOptions): WebServer {
}
});
+ // Productivity analytics — daily throughput + worker leaderboard
+ app.get('/api/productivity', (_req: Request, res: Response) => {
+ try {
+ const now = Date.now();
+ const thirtyDaysAgo = now - 30 * 24 * 60 * 60 * 1000;
+
+ // Count bead completions by day from in-memory events
+ const dayCounts = new Map();
+ for (const event of store.query({})) {
+ if (
+ event.msg === 'bead.released' &&
+ (event as Record)['reason'] === 'release_success' &&
+ event.ts >= thirtyDaysAgo
+ ) {
+ const date = new Date(event.ts).toISOString().slice(0, 10);
+ dayCounts.set(date, (dayCounts.get(date) ?? 0) + 1);
+ }
+ }
+
+ // Fill in all 30 days (including zeros)
+ const daily: { date: string; count: number }[] = [];
+ for (let i = 29; i >= 0; i--) {
+ const d = new Date(now - i * 24 * 60 * 60 * 1000);
+ const date = d.toISOString().slice(0, 10);
+ daily.push({ date, count: dayCounts.get(date) ?? 0 });
+ }
+
+ // Worker leaderboard
+ const workers = store.getWorkers().map((w) => {
+ const spanMs = Math.max(w.lastActivity - w.firstSeen, 1);
+ const beadsPerHour = parseFloat(
+ ((w.beadsCompleted / spanMs) * 3600000).toFixed(2)
+ );
+ return { id: w.id, beadsCompleted: w.beadsCompleted, beadsPerHour };
+ });
+ workers.sort((a, b) => b.beadsCompleted - a.beadsCompleted);
+
+ res.json({ daily, workers });
+ } catch (err) {
+ res.status(500).json({ error: String(err) });
+ }
+ });
+
app.get('/api/digest', (req: Request, res: Response) => {
try {
const generator = new SessionDigestGenerator(store);