> = {};
+ 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();
+ });
+ });
+});