feat(bf-4f3): add FleetSummaryBar component to web dashboard
Phase 9 UI change 1: Add always-visible summary line at top of web dashboard. - New FleetSummaryBar component showing: - N WORKING (workers in WORKING needleState) - N SELECTING (workers in SELECTING needleState) - N EXHAUSTED (workers in EXHAUSTED_IDLE or STOPPED) - N beads today (sum of beadsCompleted from workers) - N stuck (count of workers with stuck=true) - Wired into App.tsx above WorkerGrid - Comprehensive frontend tests covering all states and reactivity - CSS styling with color-coded states Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
5e029c142c
commit
072f9c71f4
4 changed files with 346 additions and 0 deletions
|
|
@ -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 = () => {
|
|||
</div>
|
||||
</header>
|
||||
|
||||
<FleetSummaryBar workers={filteredWorkers} />
|
||||
|
||||
<main className="main-content">
|
||||
<WorkerGrid
|
||||
workers={filteredWorkers}
|
||||
|
|
|
|||
58
src/web/frontend/src/components/FleetSummaryBar.tsx
Normal file
58
src/web/frontend/src/components/FleetSummaryBar.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import React from 'react';
|
||||
import { WorkerInfo, NeedleState } from '../types';
|
||||
|
||||
interface FleetSummaryBarProps {
|
||||
workers: WorkerInfo[];
|
||||
}
|
||||
|
||||
const FleetSummaryBar: React.FC<FleetSummaryBarProps> = ({ workers }) => {
|
||||
const summary = React.useMemo(() => {
|
||||
const stateCounts: Partial<Record<NeedleState, number>> = {};
|
||||
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 (
|
||||
<div className="fleet-summary-bar">
|
||||
<span className="fleet-summary-item working">
|
||||
{summary.working} WORKING
|
||||
</span>
|
||||
<span className="fleet-summary-separator">•</span>
|
||||
<span className="fleet-summary-item selecting">
|
||||
{summary.selecting} SELECTING
|
||||
</span>
|
||||
<span className="fleet-summary-separator">•</span>
|
||||
<span className="fleet-summary-item exhausted">
|
||||
{summary.exhausted} EXHAUSTED
|
||||
</span>
|
||||
<span className="fleet-summary-separator">•</span>
|
||||
<span className="fleet-summary-item beads-today">
|
||||
{summary.beadsToday} beads today
|
||||
</span>
|
||||
<span className="fleet-summary-separator">•</span>
|
||||
<span className={`fleet-summary-item stuck ${summary.stuck > 0 ? 'has-stuck' : ''}`}>
|
||||
{summary.stuck} stuck
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FleetSummaryBar;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
237
src/web/frontend/test/FleetSummaryBar.test.tsx
Normal file
237
src/web/frontend/test/FleetSummaryBar.test.tsx
Normal file
|
|
@ -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> = {}): 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(<FleetSummaryBar workers={[]} />);
|
||||
|
||||
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(<FleetSummaryBar workers={workers} />);
|
||||
|
||||
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(<FleetSummaryBar workers={workers} />);
|
||||
|
||||
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(<FleetSummaryBar workers={workers} />);
|
||||
|
||||
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(<FleetSummaryBar workers={workers} />);
|
||||
|
||||
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(<FleetSummaryBar workers={workers} />);
|
||||
|
||||
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(<FleetSummaryBar workers={workers} />);
|
||||
|
||||
expect(screen.getByText('0 stuck')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CSS classes', () => {
|
||||
it('should apply fleet-summary-bar class to container', () => {
|
||||
const { container } = render(<FleetSummaryBar workers={[]} />);
|
||||
|
||||
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(<FleetSummaryBar workers={workers} />);
|
||||
|
||||
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(<FleetSummaryBar workers={workers} />);
|
||||
|
||||
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(<FleetSummaryBar workers={workers} />);
|
||||
|
||||
expect(container.querySelector('.fleet-summary-item.exhausted')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply beads-today class to beads count', () => {
|
||||
const { container } = render(<FleetSummaryBar workers={[]} />);
|
||||
|
||||
expect(container.querySelector('.fleet-summary-item.beads-today')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply stuck class without has-stuck when zero stuck', () => {
|
||||
const { container } = render(<FleetSummaryBar workers={[]} />);
|
||||
|
||||
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(<FleetSummaryBar workers={workers} />);
|
||||
|
||||
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(<FleetSummaryBar workers={[]} />);
|
||||
|
||||
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(<FleetSummaryBar workers={workers} />);
|
||||
|
||||
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(<FleetSummaryBar workers={workers} />);
|
||||
|
||||
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(<FleetSummaryBar workers={[]} />);
|
||||
|
||||
expect(screen.getByText('0 WORKING')).toBeInTheDocument();
|
||||
|
||||
const workers = [
|
||||
createMockWorker({ id: 'worker-1', needleState: 'WORKING' }),
|
||||
createMockWorker({ id: 'worker-2', needleState: 'WORKING' }),
|
||||
];
|
||||
|
||||
rerender(<FleetSummaryBar workers={workers} />);
|
||||
|
||||
expect(screen.getByText('2 WORKING')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue