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 <noreply@anthropic.com>
This commit is contained in:
parent
c36ce6da37
commit
93b3e9e038
4 changed files with 311 additions and 0 deletions
|
|
@ -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">📝</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">🏆</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">
|
||||
|
|
|
|||
194
src/web/frontend/src/components/ProductivityPanel.tsx
Normal file
194
src/web/frontend/src/components/ProductivityPanel.tsx
Normal 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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue