feat(bf-6bx7): add /api/productivity endpoint and Productivity panel
Some checks are pending
CI / test (18.x) (push) Waiting to run
CI / test (20.x) (push) Waiting to run
CI / test (22.x) (push) Waiting to run

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 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-05-15 16:58:35 -04:00
parent c36ce6da37
commit 93b3e9e038
4 changed files with 311 additions and 0 deletions

View file

@ -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 = () => {
<span className="narrative-toggle-icon">&#x1F4DD;</span>
<span className="narrative-toggle-label">Narrative</span>
</button>
<button
className={`productivity-toggle ${showProductivity ? 'active' : ''}`}
onClick={() => setShowProductivity(!showProductivity)}
title="Productivity — daily throughput and worker leaderboard"
>
<span className="productivity-toggle-icon">&#x1F3C6;</span>
<span className="productivity-toggle-label">Productivity</span>
</button>
<button
className={`hide-test-workers-toggle ${hideTestWorkers ? 'active' : ''}`}
onClick={() => setHideTestWorkers(prev => !prev)}
@ -1007,6 +1017,13 @@ const App: React.FC = () => {
/>
)}
{showProductivity && (
<ProductivityPanel
visible={showProductivity}
onClose={() => setShowProductivity(false)}
/>
)}
{showSessionReplay && (
<div className="session-replay-panel">
<div className="session-replay-header">

View file

@ -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 (
<div className="productivity-chart-wrap">
<svg
viewBox={`0 0 ${width} ${height + 30}`}
style={{ width: '100%', maxWidth: width, display: 'block' }}
>
{slice.map((d, i) => {
const barH = Math.max((d.count / max) * height, d.count > 0 ? 2 : 0);
const x = i * colW + gap / 2;
const y = height - barH;
const showLabel = i === 0 || i === Math.floor(slice.length / 2) || i === slice.length - 1;
return (
<g key={d.date}>
<rect
x={x}
y={y}
width={barW}
height={barH}
fill="var(--accent-color, #6366f1)"
opacity={0.85}
>
<title>{`${d.date}: ${d.count} beads`}</title>
</rect>
{d.count > 0 && (
<text
x={x + barW / 2}
y={y - 3}
textAnchor="middle"
fontSize="9"
fill="var(--text-secondary, #aaa)"
>
{d.count}
</text>
)}
{showLabel && (
<text
x={x + barW / 2}
y={height + 18}
textAnchor="middle"
fontSize="9"
fill="var(--text-secondary, #aaa)"
>
{d.date.slice(5)}
</text>
)}
</g>
);
})}
<line
x1={0}
y1={height}
x2={width}
y2={height}
stroke="var(--border-color, #333)"
strokeWidth={1}
/>
</svg>
</div>
);
};
const ProductivityPanel: React.FC<ProductivityPanelProps> = ({ visible, onClose }) => {
const [data, setData] = useState<ProductivityData | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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 (
<div className="analytics-panel productivity-panel">
<div className="analytics-header">
<h3>
Productivity
{data && (
<span className="analytics-subtitle">
{totalToday} today · {total30d} last 30d
</span>
)}
</h3>
<div className="analytics-header-actions">
<button className="analytics-refresh" onClick={fetchData} disabled={loading}>
{loading ? 'Loading...' : 'Refresh'}
</button>
<button className="close-button" onClick={onClose}>x</button>
</div>
</div>
{error && <div className="analytics-error">{error}</div>}
{data && (
<div className="analytics-content">
<div className="analytics-section">
<h3 className="analytics-section-title">Daily Throughput (last 14 days)</h3>
<div className="analytics-section-body">
{data.daily.every((d) => d.count === 0) ? (
<p className="analytics-empty">No bead completions recorded yet.</p>
) : (
<BarChart data={data.daily} days={14} />
)}
</div>
</div>
<div className="analytics-section">
<h3 className="analytics-section-title">Worker Leaderboard</h3>
<div className="analytics-section-body">
{data.workers.filter((w) => w.beadsCompleted > 0).length === 0 ? (
<p className="analytics-empty">No completions recorded yet.</p>
) : (
<table className="productivity-leaderboard">
<thead>
<tr>
<th>#</th>
<th>Worker</th>
<th>Beads</th>
<th>Beads/hr</th>
</tr>
</thead>
<tbody>
{data.workers
.filter((w) => w.beadsCompleted > 0)
.map((w, i) => (
<tr key={w.id}>
<td className="productivity-rank">{i + 1}</td>
<td className="productivity-worker-id">{w.id}</td>
<td className="productivity-count">{w.beadsCompleted}</td>
<td className="productivity-rate">{w.beadsPerHour}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
</div>
)}
</div>
);
};
export default ProductivityPanel;

View file

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

View file

@ -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<string, number>();
for (const event of store.query({})) {
if (
event.msg === 'bead.released' &&
(event as Record<string, unknown>)['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);