diff --git a/src/web/frontend/src/App.tsx b/src/web/frontend/src/App.tsx index 12f3e72..0176fde 100644 --- a/src/web/frontend/src/App.tsx +++ b/src/web/frontend/src/App.tsx @@ -22,6 +22,7 @@ import GitIntegrationPanel from './components/GitIntegrationPanel'; import ProductivityPanel from './components/ProductivityPanel'; import FleetSummaryBar from './components/FleetSummaryBar'; import HistoricalSessionsPanel from './components/HistoricalSessionsPanel'; +import WorkerAnalyticsPanel from './components/WorkerAnalyticsPanel'; import CommandPalette from './components/CommandPalette'; import { extractReplayFromUrl, ReplayExport } from './utils/replayExport'; import { FocusPresetManager, createWebPresetManager, FocusPreset } from './utils/focusPresets'; @@ -262,6 +263,7 @@ const App: React.FC = () => { const [showNarrative, setShowNarrative] = useState(false); const [showProductivity, setShowProductivity] = useState(false); const [showHistoricalSessions, setShowHistoricalSessions] = useState(false); + const [showWorkerAnalytics, setShowWorkerAnalytics] = useState(false); const [budgetBannerDismissed, setBudgetBannerDismissed] = useState(false); const [hideTestWorkers, setHideTestWorkers] = useState(true); @@ -547,6 +549,8 @@ const App: React.FC = () => { setShowNarrative(true); } else if (action === 'show:sessions') { setShowHistoricalSessions(true); + } else if (action === 'show:worker-analytics') { + setShowWorkerAnalytics(true); } else if (action.startsWith('worker:')) { const workerId = action.slice('worker:'.length); setSelectedWorker(workerId); @@ -850,6 +854,14 @@ const App: React.FC = () => { 📅 Sessions + + + +
+ + + +
+ +
+ {error && ( +
+ {error} + +
+ )} + + {loading &&
Loading...
} + + {activeTab === 'leaderboard' && !loading && ( + + )} + + {activeTab === 'comparison' && ( +
+
+
+ + +
+
+ + +
+
+ + {comparison && !loading && ( +
+

Comparison Results

+ +
+
+

Worker 1

+

{comparison.worker1.workerId}

+
+
VS
+
+

Worker 2

+

{comparison.worker2.workerId}

+
+
+ + + + + + + + + + + + + + + + + + + + +
MetricWorker 1Worker 2Diff%Winner
+ +
+

Score: Worker 1 won {comparison.score.worker1} metrics, Worker 2 won {comparison.score.worker2} metrics.

+

+ Overall: {comparison.overallWinner === 'worker1' + ? 'Worker 1 wins!' + : comparison.overallWinner === 'worker2' + ? 'Worker 2 wins!' + : 'It\'s a tie!'} +

+
+
+ )} + + {!comparison && !loading && selectedWorker1 && selectedWorker2 && ( +
+

Click "Compare" to see the comparison

+ +
+ )} +
+ )} + + {activeTab === 'sessions' && !loading && ( + + )} +
+ + + ); +}; + +export default WorkerAnalyticsPanel; diff --git a/src/web/server.ts b/src/web/server.ts index 2fa1f95..d747bf8 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -1387,6 +1387,102 @@ export function createWebServer(options: WebServerOptions): WebServer { } }); + // ============================================ + // Worker Comparison Analytics API Endpoints + // ============================================ + + // Compare two workers side-by-side + app.get('/api/workers/compare', (req: Request, res: Response) => { + try { + const worker1 = req.query.worker1 as string; + const worker2 = req.query.worker2 as string; + + if (!worker1 || !worker2) { + res.status(400).json({ error: 'Missing required parameters: worker1 and worker2' }); + return; + } + + const analytics = store.getWorkerAnalytics(); + const comparison = analytics.compareWorkers(worker1, worker2); + + if (!comparison) { + res.status(404).json({ error: 'One or both workers not found' }); + return; + } + + res.json(comparison); + } catch (error) { + console.error('Error comparing workers:', error); + res.status(500).json({ + error: 'Failed to compare workers', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + + // Get per-worker metrics for leaderboard table + app.get('/api/analytics/workers', (req: Request, res: Response) => { + try { + const analytics = store.getWorkerAnalytics(); + const sortBy = req.query.sortBy as 'beadsCompleted' | 'beadsPerHour' | 'errorRate' | 'costPerBead' | 'efficiencyScore' | undefined; + const limit = req.query.limit ? parseInt(req.query.limit as string) : undefined; + + let workers = analytics.getAllWorkerMetrics(); + + // Apply sort if specified + if (sortBy) { + const lowerIsBetter = ['errorRate', 'costPerBead'].includes(sortBy); + workers.sort((a, b) => lowerIsBetter + ? (a[sortBy] as number) - (b[sortBy] as number) + : (b[sortBy] as number) - (a[sortBy] as number) + ); + } + + // Apply limit if specified + if (limit && limit > 0) { + workers = workers.slice(0, limit); + } + + res.json({ + workers, + count: workers.length, + }); + } catch (error) { + console.error('Error fetching worker metrics:', error); + res.status(500).json({ + error: 'Failed to fetch worker metrics', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + + // Get historical sessions for cross-session comparisons + app.get('/api/analytics/sessions', (req: Request, res: Response) => { + try { + const historical = store.getHistoricalStore(); + const startTime = req.query.start ? parseInt(req.query.start as string) : undefined; + const endTime = req.query.end ? parseInt(req.query.end as string) : undefined; + const limit = req.query.limit ? parseInt(req.query.limit as string) : 100; + + const sessions = historical.getSessions({ + startTime, + endTime, + limit, + }); + + res.json({ + sessions, + count: sessions.length, + }); + } catch (error) { + console.error('Error fetching historical sessions:', error); + res.status(500).json({ + error: 'Failed to fetch historical sessions', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + // Serve static frontend files const staticPath = join(__dirname, 'public'); app.use(express.static(staticPath));