diff --git a/src/web/frontend/src/App.tsx b/src/web/frontend/src/App.tsx index f4178eb..5cd8843 100644 --- a/src/web/frontend/src/App.tsx +++ b/src/web/frontend/src/App.tsx @@ -20,6 +20,7 @@ import BudgetAlertPanel, { BudgetBanner } from './components/BudgetAlertPanel'; import SessionDigestPanel from './components/SessionDigestPanel'; import GitIntegrationPanel from './components/GitIntegrationPanel'; import ProductivityPanel from './components/ProductivityPanel'; +import FleetSummaryBar from './components/FleetSummaryBar'; import CommandPalette from './components/CommandPalette'; import { extractReplayFromUrl, ReplayExport } from './utils/replayExport'; import { FocusPresetManager, createWebPresetManager, FocusPreset } from './utils/focusPresets'; @@ -883,6 +884,8 @@ const App: React.FC = () => { + +
= ({ workers }) => { + const summary = React.useMemo(() => { + const stateCounts: Partial> = {}; + let stuckCount = 0; + let totalBeadsCompleted = 0; + + for (const worker of workers) { + if (worker.needleState) { + stateCounts[worker.needleState] = (stateCounts[worker.needleState] || 0) + 1; + } + if (worker.stuck) { + stuckCount++; + } + totalBeadsCompleted += (worker as any).beadsCompleted || 0; + } + + return { + working: stateCounts.WORKING || 0, + selecting: stateCounts.SELECTING || 0, + exhausted: (stateCounts.EXHAUSTED_IDLE || 0) + (stateCounts.STOPPED || 0), + beadsToday: totalBeadsCompleted, + stuck: stuckCount, + }; + }, [workers]); + + return ( +
+ + {summary.working} WORKING + + + + {summary.selecting} SELECTING + + + + {summary.exhausted} EXHAUSTED + + + + {summary.beadsToday} beads today + + + 0 ? 'has-stuck' : ''}`}> + {summary.stuck} stuck + +
+ ); +}; + +export default FleetSummaryBar; diff --git a/src/web/frontend/src/index.css b/src/web/frontend/src/index.css index d35d587..a52be72 100644 --- a/src/web/frontend/src/index.css +++ b/src/web/frontend/src/index.css @@ -356,6 +356,54 @@ body { font-weight: 600; } +/* Fleet Summary Bar */ +.fleet-summary-bar { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + background: var(--bg-tertiary); + border-bottom: 1px solid var(--border-color); + font-size: 0.75rem; + flex-wrap: wrap; +} + +.fleet-summary-item { + display: flex; + align-items: center; + font-weight: 500; +} + +.fleet-summary-item.working { + color: var(--success); +} + +.fleet-summary-item.selecting { + color: var(--warning); +} + +.fleet-summary-item.exhausted { + color: var(--text-secondary); +} + +.fleet-summary-item.beads-today { + color: var(--info); +} + +.fleet-summary-item.stuck { + color: var(--text-secondary); +} + +.fleet-summary-item.stuck.has-stuck { + color: var(--error); + font-weight: 600; +} + +.fleet-summary-separator { + color: var(--text-secondary); + opacity: 0.5; +} + .main-content { display: grid; grid-template-columns: 300px 1fr; diff --git a/src/web/frontend/test/FleetSummaryBar.test.tsx b/src/web/frontend/test/FleetSummaryBar.test.tsx new file mode 100644 index 0000000..434a2a9 --- /dev/null +++ b/src/web/frontend/test/FleetSummaryBar.test.tsx @@ -0,0 +1,237 @@ +/** + * Tests for FleetSummaryBar component + * @vitest-environment jsdom + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import FleetSummaryBar from '../src/components/FleetSummaryBar'; +import { WorkerInfo } from '../src/types'; + +describe('FleetSummaryBar', () => { + const createMockWorker = (overrides: Partial = {}): WorkerInfo => ({ + id: 'worker-alpha', + lastSeen: new Date().toISOString(), + eventCount: 10, + status: 'active', + recentEvents: [], + ...overrides, + }); + + afterEach(() => { + cleanup(); + }); + + describe('rendering', () => { + it('should render empty state when no workers', () => { + render(); + + expect(screen.getByText('0 WORKING')).toBeInTheDocument(); + expect(screen.getByText('0 SELECTING')).toBeInTheDocument(); + expect(screen.getByText('0 EXHAUSTED')).toBeInTheDocument(); + expect(screen.getByText('0 beads today')).toBeInTheDocument(); + expect(screen.getByText('0 stuck')).toBeInTheDocument(); + }); + + it('should render worker counts by needle state', () => { + const workers = [ + createMockWorker({ id: 'worker-1', needleState: 'WORKING' }), + createMockWorker({ id: 'worker-2', needleState: 'WORKING' }), + createMockWorker({ id: 'worker-3', needleState: 'SELECTING' }), + createMockWorker({ id: 'worker-4', needleState: 'EXHAUSTED_IDLE' }), + createMockWorker({ id: 'worker-5', needleState: 'STOPPED' }), + ]; + + render(); + + expect(screen.getByText('2 WORKING')).toBeInTheDocument(); + expect(screen.getByText('1 SELECTING')).toBeInTheDocument(); + expect(screen.getByText('2 EXHAUSTED')).toBeInTheDocument(); + }); + + it('should count EXHAUSTED_IDLE and STOPPED as exhausted', () => { + const workers = [ + createMockWorker({ id: 'worker-1', needleState: 'EXHAUSTED_IDLE' }), + createMockWorker({ id: 'worker-2', needleState: 'STOPPED' }), + createMockWorker({ id: 'worker-3', needleState: 'EXHAUSTED_IDLE' }), + ]; + + render(); + + expect(screen.getByText('3 EXHAUSTED')).toBeInTheDocument(); + }); + + it('should display total beads completed today', () => { + const workers = [ + createMockWorker({ id: 'worker-1' }), + createMockWorker({ id: 'worker-2' }), + createMockWorker({ id: 'worker-3' }), + ]; + // Add beadsCompleted via type extension + (workers[0] as any).beadsCompleted = 5; + (workers[1] as any).beadsCompleted = 3; + (workers[2] as any).beadsCompleted = 7; + + render(); + + expect(screen.getByText('15 beads today')).toBeInTheDocument(); + }); + + it('should display zero beads when none completed', () => { + const workers = [ + createMockWorker({ id: 'worker-1' }), + createMockWorker({ id: 'worker-2' }), + ]; + + render(); + + expect(screen.getByText('0 beads today')).toBeInTheDocument(); + }); + + it('should count stuck workers', () => { + const workers = [ + createMockWorker({ id: 'worker-1', stuck: true }), + createMockWorker({ id: 'worker-2', stuck: false }), + createMockWorker({ id: 'worker-3', stuck: true }), + createMockWorker({ id: 'worker-4', stuck: true }), + ]; + + render(); + + expect(screen.getByText('3 stuck')).toBeInTheDocument(); + }); + + it('should display zero stuck when none are stuck', () => { + const workers = [ + createMockWorker({ id: 'worker-1', stuck: false }), + createMockWorker({ id: 'worker-2', stuck: false }), + ]; + + render(); + + expect(screen.getByText('0 stuck')).toBeInTheDocument(); + }); + }); + + describe('CSS classes', () => { + it('should apply fleet-summary-bar class to container', () => { + const { container } = render(); + + expect(container.querySelector('.fleet-summary-bar')).toBeInTheDocument(); + }); + + it('should apply working class to WORKING count', () => { + const workers = [createMockWorker({ id: 'worker-1', needleState: 'WORKING' })]; + const { container } = render(); + + expect(container.querySelector('.fleet-summary-item.working')).toBeInTheDocument(); + }); + + it('should apply selecting class to SELECTING count', () => { + const workers = [createMockWorker({ id: 'worker-1', needleState: 'SELECTING' })]; + const { container } = render(); + + expect(container.querySelector('.fleet-summary-item.selecting')).toBeInTheDocument(); + }); + + it('should apply exhausted class to EXHAUSTED count', () => { + const workers = [createMockWorker({ id: 'worker-1', needleState: 'EXHAUSTED_IDLE' })]; + const { container } = render(); + + expect(container.querySelector('.fleet-summary-item.exhausted')).toBeInTheDocument(); + }); + + it('should apply beads-today class to beads count', () => { + const { container } = render(); + + expect(container.querySelector('.fleet-summary-item.beads-today')).toBeInTheDocument(); + }); + + it('should apply stuck class without has-stuck when zero stuck', () => { + const { container } = render(); + + const stuckEl = container.querySelector('.fleet-summary-item.stuck'); + expect(stuckEl).toBeInTheDocument(); + expect(stuckEl).not.toHaveClass('has-stuck'); + }); + + it('should apply stuck class with has-stuck when workers are stuck', () => { + const workers = [createMockWorker({ id: 'worker-1', stuck: true })]; + const { container } = render(); + + const stuckEl = container.querySelector('.fleet-summary-item.stuck'); + expect(stuckEl).toBeInTheDocument(); + expect(stuckEl).toHaveClass('has-stuck'); + }); + + it('should apply fleet-summary-separator class to separators', () => { + const { container } = render(); + + const separators = container.querySelectorAll('.fleet-summary-separator'); + expect(separators.length).toBe(5); + }); + }); + + describe('comprehensive fleet state', () => { + it('should accurately reflect complex fleet state', () => { + const workers = [ + createMockWorker({ id: 'worker-1', needleState: 'WORKING', stuck: false }), + createMockWorker({ id: 'worker-2', needleState: 'WORKING', stuck: false }), + createMockWorker({ id: 'worker-3', needleState: 'WORKING', stuck: true }), + createMockWorker({ id: 'worker-4', needleState: 'SELECTING', stuck: false }), + createMockWorker({ id: 'worker-5', needleState: 'SELECTING', stuck: false }), + createMockWorker({ id: 'worker-6', needleState: 'CLAIMING', stuck: false }), + createMockWorker({ id: 'worker-7', needleState: 'EXHAUSTED_IDLE', stuck: false }), + createMockWorker({ id: 'worker-8', needleState: 'STOPPED', stuck: false }), + createMockWorker({ id: 'worker-9', needleState: 'BOOTING', stuck: false }), + ]; + // Add beadsCompleted + (workers[0] as any).beadsCompleted = 10; + (workers[1] as any).beadsCompleted = 5; + (workers[3] as any).beadsCompleted = 3; + (workers[6] as any).beadsCompleted = 2; + + render(); + + expect(screen.getByText('3 WORKING')).toBeInTheDocument(); + expect(screen.getByText('2 SELECTING')).toBeInTheDocument(); + expect(screen.getByText('2 EXHAUSTED')).toBeInTheDocument(); + expect(screen.getByText('20 beads today')).toBeInTheDocument(); + expect(screen.getByText('1 stuck')).toBeInTheDocument(); + + const stuckEl = screen.getByText('1 stuck'); + expect(stuckEl).toHaveClass('has-stuck'); + }); + + it('should only show needle states that exist in the fleet', () => { + const workers = [ + createMockWorker({ id: 'worker-1', needleState: 'WORKING' }), + createMockWorker({ id: 'worker-2', needleState: 'WORKING' }), + ]; + + render(); + + expect(screen.getByText('2 WORKING')).toBeInTheDocument(); + expect(screen.getByText('0 SELECTING')).toBeInTheDocument(); + expect(screen.getByText('0 EXHAUSTED')).toBeInTheDocument(); + // All states should still be displayed with zero counts + }); + }); + + describe('reactivity', () => { + it('should update counts when workers change', () => { + const { rerender } = render(); + + expect(screen.getByText('0 WORKING')).toBeInTheDocument(); + + const workers = [ + createMockWorker({ id: 'worker-1', needleState: 'WORKING' }), + createMockWorker({ id: 'worker-2', needleState: 'WORKING' }), + ]; + + rerender(); + + expect(screen.getByText('2 WORKING')).toBeInTheDocument(); + }); + }); +});