Skip to content

Commit 1a6e499

Browse files
authored
feat(web-ui): pipeline progress indicator (Think→Build→Prove→Ship) (#466)
## Summary - Adds `PipelineProgressBar` to `AppLayout` — a persistent horizontal stepper showing Think→Build→Prove→Ship on every page - `usePipelineStatus` hook aggregates phase completion from 4 existing API endpoints via SWR tuple keys (no new backend work) - Bar auto-hides on `/` and non-pipeline routes; responsive (abbreviated labels mobile, full on lg:) - `PhaseStatus`/`PipelineStatus` types moved to `src/types/index.ts` per project guidelines - `isError` field added to surface API failures per phase ## Validation - Review feedback: All addressed (2 rounds — 7 items fixed including comment/code mismatch, unused imports, type location, isError field, aria-current assertions, SWR v2 tuple keys) - Demo: All 5 acceptance criteria verified via Showboat - Tests: 373/373 passing (26 new tests added) - CI: All checks green (Backend Tests, Code Quality, claude-review, CodeRabbit, GitGuardian) - Linting: Clean in all changed files Closes #466
1 parent a22d167 commit 1a6e499

7 files changed

Lines changed: 604 additions & 1 deletion

File tree

web-ui/__mocks__/@hugeicons/react.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ module.exports = {
3737
SentIcon: createIconMock('SentIcon'),
3838
// AppSidebar
3939
Home01Icon: createIconMock('Home01Icon'),
40+
// PipelineProgressBar
41+
Tick01Icon: createIconMock('Tick01Icon'),
4042
// Task Board components
4143
PlayCircleIcon: createIconMock('PlayCircleIcon'),
4244
LinkCircleIcon: createIconMock('LinkCircleIcon'),
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import { render, screen } from '@testing-library/react';
2+
import { usePathname } from 'next/navigation';
3+
import { PipelineProgressBar } from '@/components/layout/PipelineProgressBar';
4+
import { usePipelineStatus } from '@/hooks/usePipelineStatus';
5+
6+
jest.mock('@/hooks/usePipelineStatus');
7+
jest.mock('next/navigation', () => ({
8+
usePathname: jest.fn(),
9+
useRouter: jest.fn(() => ({ push: jest.fn() })),
10+
}));
11+
12+
const mockUsePipelineStatus = usePipelineStatus as jest.MockedFunction<typeof usePipelineStatus>;
13+
const mockUsePathname = usePathname as jest.MockedFunction<typeof usePathname>;
14+
15+
const allIncomplete = {
16+
think: { isComplete: false, isLoading: false, isError: false },
17+
build: { isComplete: false, isLoading: false, isError: false },
18+
prove: { isComplete: false, isLoading: false, isError: false },
19+
ship: { isComplete: false, isLoading: false, isError: false },
20+
};
21+
22+
const allComplete = {
23+
think: { isComplete: true, isLoading: false, isError: false },
24+
build: { isComplete: true, isLoading: false, isError: false },
25+
prove: { isComplete: true, isLoading: false, isError: false },
26+
ship: { isComplete: true, isLoading: false, isError: false },
27+
};
28+
29+
describe('PipelineProgressBar', () => {
30+
beforeEach(() => {
31+
jest.clearAllMocks();
32+
});
33+
34+
it('renders all four phase labels on desktop', () => {
35+
mockUsePathname.mockReturnValue('/prd');
36+
mockUsePipelineStatus.mockReturnValue(allIncomplete);
37+
38+
render(<PipelineProgressBar />);
39+
40+
expect(screen.getByText('Think')).toBeInTheDocument();
41+
expect(screen.getByText('Build')).toBeInTheDocument();
42+
expect(screen.getByText('Prove')).toBeInTheDocument();
43+
expect(screen.getByText('Ship')).toBeInTheDocument();
44+
});
45+
46+
it('returns null on root path /', () => {
47+
mockUsePathname.mockReturnValue('/');
48+
mockUsePipelineStatus.mockReturnValue(allIncomplete);
49+
50+
const { container } = render(<PipelineProgressBar />);
51+
expect(container.firstChild).toBeNull();
52+
});
53+
54+
it('returns null on non-pipeline path (e.g. /settings)', () => {
55+
mockUsePathname.mockReturnValue('/settings');
56+
mockUsePipelineStatus.mockReturnValue(allIncomplete);
57+
58+
const { container } = render(<PipelineProgressBar />);
59+
expect(container.firstChild).toBeNull();
60+
});
61+
62+
it('highlights the Think phase when on /prd with aria-current', () => {
63+
mockUsePathname.mockReturnValue('/prd');
64+
mockUsePipelineStatus.mockReturnValue(allIncomplete);
65+
66+
render(<PipelineProgressBar />);
67+
68+
const thinkLink = screen.getByRole('link', { name: /think/i });
69+
expect(thinkLink).toHaveAttribute('href', '/prd');
70+
expect(thinkLink).toHaveAttribute('aria-current', 'step');
71+
});
72+
73+
it('highlights the Build phase when on /tasks', () => {
74+
mockUsePathname.mockReturnValue('/tasks');
75+
mockUsePipelineStatus.mockReturnValue(allIncomplete);
76+
77+
render(<PipelineProgressBar />);
78+
79+
const buildLink = screen.getByRole('link', { name: /build/i });
80+
expect(buildLink).toHaveAttribute('href', '/tasks');
81+
expect(buildLink).toHaveAttribute('aria-current', 'step');
82+
});
83+
84+
it('highlights the Build phase when on /execution', () => {
85+
mockUsePathname.mockReturnValue('/execution');
86+
mockUsePipelineStatus.mockReturnValue(allIncomplete);
87+
88+
render(<PipelineProgressBar />);
89+
90+
const buildLink = screen.getByRole('link', { name: /build/i });
91+
expect(buildLink).toHaveAttribute('href', '/tasks');
92+
expect(buildLink).toHaveAttribute('aria-current', 'step');
93+
});
94+
95+
it('highlights the Build phase when on /blockers', () => {
96+
mockUsePathname.mockReturnValue('/blockers');
97+
mockUsePipelineStatus.mockReturnValue(allIncomplete);
98+
99+
render(<PipelineProgressBar />);
100+
101+
const buildLink = screen.getByRole('link', { name: /build/i });
102+
expect(buildLink).toHaveAttribute('href', '/tasks');
103+
expect(buildLink).toHaveAttribute('aria-current', 'step');
104+
});
105+
106+
it('highlights the Prove phase when on /proof', () => {
107+
mockUsePathname.mockReturnValue('/proof');
108+
mockUsePipelineStatus.mockReturnValue(allIncomplete);
109+
110+
render(<PipelineProgressBar />);
111+
112+
const proveLink = screen.getByRole('link', { name: /prove/i });
113+
expect(proveLink).toHaveAttribute('href', '/proof');
114+
expect(proveLink).toHaveAttribute('aria-current', 'step');
115+
});
116+
117+
it('highlights the Ship phase when on /review', () => {
118+
mockUsePathname.mockReturnValue('/review');
119+
mockUsePipelineStatus.mockReturnValue(allIncomplete);
120+
121+
render(<PipelineProgressBar />);
122+
123+
const shipLink = screen.getByRole('link', { name: /ship/i });
124+
expect(shipLink).toHaveAttribute('href', '/review');
125+
expect(shipLink).toHaveAttribute('aria-current', 'step');
126+
});
127+
128+
it('shows checkmark icon for completed phases', () => {
129+
mockUsePathname.mockReturnValue('/tasks');
130+
mockUsePipelineStatus.mockReturnValue({
131+
...allIncomplete,
132+
think: { isComplete: true, isLoading: false, isError: false },
133+
});
134+
135+
render(<PipelineProgressBar />);
136+
137+
expect(screen.getByTestId('icon-Tick01Icon')).toBeInTheDocument();
138+
});
139+
140+
it('shows all checkmarks when all phases complete', () => {
141+
mockUsePathname.mockReturnValue('/review');
142+
mockUsePipelineStatus.mockReturnValue(allComplete);
143+
144+
render(<PipelineProgressBar />);
145+
146+
expect(screen.getAllByTestId('icon-Tick01Icon')).toHaveLength(4);
147+
});
148+
149+
it('each phase link navigates to correct route', () => {
150+
mockUsePathname.mockReturnValue('/prd');
151+
mockUsePipelineStatus.mockReturnValue(allIncomplete);
152+
153+
render(<PipelineProgressBar />);
154+
155+
expect(screen.getByRole('link', { name: /think/i })).toHaveAttribute('href', '/prd');
156+
expect(screen.getByRole('link', { name: /build/i })).toHaveAttribute('href', '/tasks');
157+
expect(screen.getByRole('link', { name: /prove/i })).toHaveAttribute('href', '/proof');
158+
expect(screen.getByRole('link', { name: /ship/i })).toHaveAttribute('href', '/review');
159+
});
160+
});

0 commit comments

Comments
 (0)