From 93b3e9e038053dedc9e1dfc22e89fed9335951af Mon Sep 17 00:00:00 2001 From: jedarden Date: Fri, 15 May 2026 16:58:35 -0400 Subject: [PATCH] feat(bf-6bx7): add /api/productivity endpoint and Productivity panel Adds GET /api/productivity returning daily bead completion counts (last 30 days) from bead.released/release_success events and a worker leaderboard sorted by beadsCompleted. Adds a Productivity tab in the web UI with a 14-day SVG bar chart and a worker leaderboard table. Co-Authored-By: Claude Sonnet 4.6 --- src/web/frontend/src/App.tsx | 17 ++ .../src/components/ProductivityPanel.tsx | 194 ++++++++++++++++++ src/web/frontend/src/index.css | 57 +++++ src/web/server.ts | 43 ++++ 4 files changed, 311 insertions(+) create mode 100644 src/web/frontend/src/components/ProductivityPanel.tsx diff --git a/src/web/frontend/src/App.tsx b/src/web/frontend/src/App.tsx index 81a9b84..f4178eb 100644 --- a/src/web/frontend/src/App.tsx +++ b/src/web/frontend/src/App.tsx @@ -19,6 +19,7 @@ import SemanticNarrativePanel from './components/SemanticNarrativePanel'; import BudgetAlertPanel, { BudgetBanner } from './components/BudgetAlertPanel'; import SessionDigestPanel from './components/SessionDigestPanel'; import GitIntegrationPanel from './components/GitIntegrationPanel'; +import ProductivityPanel from './components/ProductivityPanel'; import CommandPalette from './components/CommandPalette'; import { extractReplayFromUrl, ReplayExport } from './utils/replayExport'; import { FocusPresetManager, createWebPresetManager, FocusPreset } from './utils/focusPresets'; @@ -257,6 +258,7 @@ const App: React.FC = () => { const [showSessionDigest, setShowSessionDigest] = useState(false); const [showGitIntegration, setShowGitIntegration] = useState(false); const [showNarrative, setShowNarrative] = useState(false); + const [showProductivity, setShowProductivity] = useState(false); const [budgetBannerDismissed, setBudgetBannerDismissed] = useState(false); const [hideTestWorkers, setHideTestWorkers] = useState(true); @@ -827,6 +829,14 @@ const App: React.FC = () => { 📝 Narrative + + + + + + {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.

+ ) : ( + + + + + + + + + + + {data.workers + .filter((w) => w.beadsCompleted > 0) + .map((w, i) => ( + + + + + + + ))} + +
#WorkerBeadsBeads/hr
{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);