diff --git a/web-ui/__mocks__/@hugeicons/react.js b/web-ui/__mocks__/@hugeicons/react.js index 13e2748a..d3fa2e15 100644 --- a/web-ui/__mocks__/@hugeicons/react.js +++ b/web-ui/__mocks__/@hugeicons/react.js @@ -37,6 +37,8 @@ module.exports = { SentIcon: createIconMock('SentIcon'), // AppSidebar Home01Icon: createIconMock('Home01Icon'), + // PipelineProgressBar + Tick01Icon: createIconMock('Tick01Icon'), // Task Board components PlayCircleIcon: createIconMock('PlayCircleIcon'), LinkCircleIcon: createIconMock('LinkCircleIcon'), diff --git a/web-ui/src/__tests__/components/layout/PipelineProgressBar.test.tsx b/web-ui/src/__tests__/components/layout/PipelineProgressBar.test.tsx new file mode 100644 index 00000000..8b62c9a7 --- /dev/null +++ b/web-ui/src/__tests__/components/layout/PipelineProgressBar.test.tsx @@ -0,0 +1,160 @@ +import { render, screen } from '@testing-library/react'; +import { usePathname } from 'next/navigation'; +import { PipelineProgressBar } from '@/components/layout/PipelineProgressBar'; +import { usePipelineStatus } from '@/hooks/usePipelineStatus'; + +jest.mock('@/hooks/usePipelineStatus'); +jest.mock('next/navigation', () => ({ + usePathname: jest.fn(), + useRouter: jest.fn(() => ({ push: jest.fn() })), +})); + +const mockUsePipelineStatus = usePipelineStatus as jest.MockedFunction; +const mockUsePathname = usePathname as jest.MockedFunction; + +const allIncomplete = { + think: { isComplete: false, isLoading: false, isError: false }, + build: { isComplete: false, isLoading: false, isError: false }, + prove: { isComplete: false, isLoading: false, isError: false }, + ship: { isComplete: false, isLoading: false, isError: false }, +}; + +const allComplete = { + think: { isComplete: true, isLoading: false, isError: false }, + build: { isComplete: true, isLoading: false, isError: false }, + prove: { isComplete: true, isLoading: false, isError: false }, + ship: { isComplete: true, isLoading: false, isError: false }, +}; + +describe('PipelineProgressBar', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders all four phase labels on desktop', () => { + mockUsePathname.mockReturnValue('/prd'); + mockUsePipelineStatus.mockReturnValue(allIncomplete); + + render(); + + expect(screen.getByText('Think')).toBeInTheDocument(); + expect(screen.getByText('Build')).toBeInTheDocument(); + expect(screen.getByText('Prove')).toBeInTheDocument(); + expect(screen.getByText('Ship')).toBeInTheDocument(); + }); + + it('returns null on root path /', () => { + mockUsePathname.mockReturnValue('/'); + mockUsePipelineStatus.mockReturnValue(allIncomplete); + + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('returns null on non-pipeline path (e.g. /settings)', () => { + mockUsePathname.mockReturnValue('/settings'); + mockUsePipelineStatus.mockReturnValue(allIncomplete); + + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('highlights the Think phase when on /prd with aria-current', () => { + mockUsePathname.mockReturnValue('/prd'); + mockUsePipelineStatus.mockReturnValue(allIncomplete); + + render(); + + const thinkLink = screen.getByRole('link', { name: /think/i }); + expect(thinkLink).toHaveAttribute('href', '/prd'); + expect(thinkLink).toHaveAttribute('aria-current', 'step'); + }); + + it('highlights the Build phase when on /tasks', () => { + mockUsePathname.mockReturnValue('/tasks'); + mockUsePipelineStatus.mockReturnValue(allIncomplete); + + render(); + + const buildLink = screen.getByRole('link', { name: /build/i }); + expect(buildLink).toHaveAttribute('href', '/tasks'); + expect(buildLink).toHaveAttribute('aria-current', 'step'); + }); + + it('highlights the Build phase when on /execution', () => { + mockUsePathname.mockReturnValue('/execution'); + mockUsePipelineStatus.mockReturnValue(allIncomplete); + + render(); + + const buildLink = screen.getByRole('link', { name: /build/i }); + expect(buildLink).toHaveAttribute('href', '/tasks'); + expect(buildLink).toHaveAttribute('aria-current', 'step'); + }); + + it('highlights the Build phase when on /blockers', () => { + mockUsePathname.mockReturnValue('/blockers'); + mockUsePipelineStatus.mockReturnValue(allIncomplete); + + render(); + + const buildLink = screen.getByRole('link', { name: /build/i }); + expect(buildLink).toHaveAttribute('href', '/tasks'); + expect(buildLink).toHaveAttribute('aria-current', 'step'); + }); + + it('highlights the Prove phase when on /proof', () => { + mockUsePathname.mockReturnValue('/proof'); + mockUsePipelineStatus.mockReturnValue(allIncomplete); + + render(); + + const proveLink = screen.getByRole('link', { name: /prove/i }); + expect(proveLink).toHaveAttribute('href', '/proof'); + expect(proveLink).toHaveAttribute('aria-current', 'step'); + }); + + it('highlights the Ship phase when on /review', () => { + mockUsePathname.mockReturnValue('/review'); + mockUsePipelineStatus.mockReturnValue(allIncomplete); + + render(); + + const shipLink = screen.getByRole('link', { name: /ship/i }); + expect(shipLink).toHaveAttribute('href', '/review'); + expect(shipLink).toHaveAttribute('aria-current', 'step'); + }); + + it('shows checkmark icon for completed phases', () => { + mockUsePathname.mockReturnValue('/tasks'); + mockUsePipelineStatus.mockReturnValue({ + ...allIncomplete, + think: { isComplete: true, isLoading: false, isError: false }, + }); + + render(); + + expect(screen.getByTestId('icon-Tick01Icon')).toBeInTheDocument(); + }); + + it('shows all checkmarks when all phases complete', () => { + mockUsePathname.mockReturnValue('/review'); + mockUsePipelineStatus.mockReturnValue(allComplete); + + render(); + + expect(screen.getAllByTestId('icon-Tick01Icon')).toHaveLength(4); + }); + + it('each phase link navigates to correct route', () => { + mockUsePathname.mockReturnValue('/prd'); + mockUsePipelineStatus.mockReturnValue(allIncomplete); + + render(); + + expect(screen.getByRole('link', { name: /think/i })).toHaveAttribute('href', '/prd'); + expect(screen.getByRole('link', { name: /build/i })).toHaveAttribute('href', '/tasks'); + expect(screen.getByRole('link', { name: /prove/i })).toHaveAttribute('href', '/proof'); + expect(screen.getByRole('link', { name: /ship/i })).toHaveAttribute('href', '/review'); + }); +}); diff --git a/web-ui/src/__tests__/hooks/usePipelineStatus.test.ts b/web-ui/src/__tests__/hooks/usePipelineStatus.test.ts new file mode 100644 index 00000000..f549bbe9 --- /dev/null +++ b/web-ui/src/__tests__/hooks/usePipelineStatus.test.ts @@ -0,0 +1,250 @@ +import { renderHook } from '@testing-library/react'; +import useSWR from 'swr'; +import { usePipelineStatus } from '@/hooks/usePipelineStatus'; +import { getSelectedWorkspacePath } from '@/lib/workspace-storage'; + +jest.mock('swr'); +jest.mock('@/lib/workspace-storage'); + +const mockUseSWR = useSWR as jest.MockedFunction; +const mockGetWorkspacePath = getSelectedWorkspacePath as jest.MockedFunction< + typeof getSelectedWorkspacePath +>; + +// Helper to set up SWR mocks for a given scenario, keyed by URL prefix +function mockSWRCalls({ + prd, + tasks, + proof, + review, +}: { + prd?: { data?: unknown; isLoading?: boolean; error?: unknown }; + tasks?: { data?: unknown; isLoading?: boolean; error?: unknown }; + proof?: { data?: unknown; isLoading?: boolean; error?: unknown }; + review?: { data?: unknown; isLoading?: boolean; error?: unknown }; +}) { + mockUseSWR.mockImplementation((key: unknown, ..._rest: unknown[]) => { + // Keys are now tuples: ['pipeline/prd', workspacePath] etc. + const keyPrefix = Array.isArray(key) ? (key[0] as string) : (typeof key === 'string' ? key : ''); + let scenario: { data?: unknown; isLoading?: boolean; error?: unknown } = {}; + if (keyPrefix.includes('pipeline/prd')) scenario = prd ?? {}; + else if (keyPrefix.includes('pipeline/tasks')) scenario = tasks ?? {}; + else if (keyPrefix.includes('pipeline/proof')) scenario = proof ?? {}; + else if (keyPrefix.includes('pipeline/review')) scenario = review ?? {}; + + return { + data: scenario.data ?? undefined, + isLoading: scenario.isLoading ?? false, + error: scenario.error ?? undefined, + isValidating: false, + mutate: jest.fn(), + } as ReturnType; + }); +} + +describe('usePipelineStatus', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetWorkspacePath.mockReturnValue('/test/workspace'); + }); + + it('returns all phases incomplete when no data yet', () => { + mockSWRCalls({ + prd: { isLoading: true }, + tasks: { isLoading: true }, + proof: { isLoading: true }, + review: { isLoading: true }, + }); + + const { result } = renderHook(() => usePipelineStatus()); + expect(result.current.think.isComplete).toBe(false); + expect(result.current.build.isComplete).toBe(false); + expect(result.current.prove.isComplete).toBe(false); + expect(result.current.ship.isComplete).toBe(false); + }); + + it('think phase complete when PRD data returned', () => { + mockSWRCalls({ + prd: { data: { id: 'prd-1', content: 'some content' } }, + tasks: { data: { tasks: [], total: 0, by_status: { DONE: 0, MERGED: 0 } } }, + proof: { data: { total: 0, open: 0, satisfied: 0, waived: 0 } }, + review: { data: { files_changed: 5 } }, + }); + + const { result } = renderHook(() => usePipelineStatus()); + expect(result.current.think.isComplete).toBe(true); + }); + + it('think phase incomplete when PRD endpoint returns no data (404)', () => { + mockSWRCalls({ + prd: { data: undefined }, + tasks: { data: { tasks: [], total: 0, by_status: { DONE: 0, MERGED: 0 } } }, + proof: { data: { total: 0, open: 0, satisfied: 0, waived: 0 } }, + review: { data: { files_changed: 5 } }, + }); + + const { result } = renderHook(() => usePipelineStatus()); + expect(result.current.think.isComplete).toBe(false); + }); + + it('build phase complete when at least one task is DONE', () => { + mockSWRCalls({ + prd: { data: undefined }, + tasks: { + data: { + tasks: [], + total: 3, + by_status: { BACKLOG: 1, READY: 1, IN_PROGRESS: 0, DONE: 1, BLOCKED: 0, FAILED: 0, MERGED: 0 }, + }, + }, + proof: { data: { total: 0, open: 0, satisfied: 0, waived: 0 } }, + review: { data: { files_changed: 5 } }, + }); + + const { result } = renderHook(() => usePipelineStatus()); + expect(result.current.build.isComplete).toBe(true); + }); + + it('build phase complete when at least one task is MERGED', () => { + mockSWRCalls({ + prd: { data: undefined }, + tasks: { + data: { + tasks: [], + total: 2, + by_status: { BACKLOG: 0, READY: 0, IN_PROGRESS: 0, DONE: 0, BLOCKED: 0, FAILED: 0, MERGED: 1 }, + }, + }, + proof: { data: { total: 0, open: 0, satisfied: 0, waived: 0 } }, + review: { data: { files_changed: 5 } }, + }); + + const { result } = renderHook(() => usePipelineStatus()); + expect(result.current.build.isComplete).toBe(true); + }); + + it('build phase incomplete when no tasks are done or merged', () => { + mockSWRCalls({ + prd: { data: undefined }, + tasks: { + data: { + tasks: [], + total: 2, + by_status: { BACKLOG: 2, READY: 0, IN_PROGRESS: 0, DONE: 0, BLOCKED: 0, FAILED: 0, MERGED: 0 }, + }, + }, + proof: { data: { total: 0, open: 0, satisfied: 0, waived: 0 } }, + review: { data: { files_changed: 5 } }, + }); + + const { result } = renderHook(() => usePipelineStatus()); + expect(result.current.build.isComplete).toBe(false); + }); + + it('prove phase complete when proof has requirements and none open', () => { + mockSWRCalls({ + prd: { data: undefined }, + tasks: { data: { tasks: [], total: 0, by_status: { DONE: 0, MERGED: 0 } } }, + proof: { data: { total: 3, open: 0, satisfied: 3, waived: 0 } }, + review: { data: { files_changed: 5 } }, + }); + + const { result } = renderHook(() => usePipelineStatus()); + expect(result.current.prove.isComplete).toBe(true); + }); + + it('prove phase incomplete when open requirements remain', () => { + mockSWRCalls({ + prd: { data: undefined }, + tasks: { data: { tasks: [], total: 0, by_status: { DONE: 0, MERGED: 0 } } }, + proof: { data: { total: 3, open: 1, satisfied: 2, waived: 0 } }, + review: { data: { files_changed: 5 } }, + }); + + const { result } = renderHook(() => usePipelineStatus()); + expect(result.current.prove.isComplete).toBe(false); + }); + + it('prove phase incomplete when total is 0 (no proof requirements)', () => { + mockSWRCalls({ + prd: { data: undefined }, + tasks: { data: { tasks: [], total: 0, by_status: { DONE: 0, MERGED: 0 } } }, + proof: { data: { total: 0, open: 0, satisfied: 0, waived: 0 } }, + review: { data: { files_changed: 5 } }, + }); + + const { result } = renderHook(() => usePipelineStatus()); + expect(result.current.prove.isComplete).toBe(false); + }); + + it('ship phase complete when files_changed is 0', () => { + mockSWRCalls({ + prd: { data: undefined }, + tasks: { data: { tasks: [], total: 0, by_status: { DONE: 0, MERGED: 0 } } }, + proof: { data: { total: 0, open: 0, satisfied: 0, waived: 0 } }, + review: { data: { files_changed: 0 } }, + }); + + const { result } = renderHook(() => usePipelineStatus()); + expect(result.current.ship.isComplete).toBe(true); + }); + + it('ship phase incomplete when there are uncommitted changes', () => { + mockSWRCalls({ + prd: { data: undefined }, + tasks: { data: { tasks: [], total: 0, by_status: { DONE: 0, MERGED: 0 } } }, + proof: { data: { total: 0, open: 0, satisfied: 0, waived: 0 } }, + review: { data: { files_changed: 3 } }, + }); + + const { result } = renderHook(() => usePipelineStatus()); + expect(result.current.ship.isComplete).toBe(false); + }); + + it('reflects individual phase loading states accurately', () => { + mockSWRCalls({ + prd: { isLoading: true }, + tasks: { isLoading: false }, + proof: { isLoading: true }, + review: { isLoading: false }, + }); + + const { result } = renderHook(() => usePipelineStatus()); + expect(result.current.think.isLoading).toBe(true); + expect(result.current.build.isLoading).toBe(false); + expect(result.current.prove.isLoading).toBe(true); + expect(result.current.ship.isLoading).toBe(false); + }); + + it('surfaces isError when an API call fails', () => { + mockSWRCalls({ + prd: { error: new Error('404') }, + tasks: { data: { tasks: [], total: 0, by_status: { DONE: 0, MERGED: 0 } } }, + proof: { data: { total: 0, open: 0, satisfied: 0, waived: 0 } }, + review: { data: { files_changed: 5 } }, + }); + + const { result } = renderHook(() => usePipelineStatus()); + expect(result.current.think.isError).toBe(true); + expect(result.current.build.isError).toBe(false); + expect(result.current.prove.isError).toBe(false); + expect(result.current.ship.isError).toBe(false); + }); + + it('returns null SWR keys when no workspace selected', () => { + mockGetWorkspacePath.mockReturnValue(null); + mockSWRCalls({ + prd: { data: undefined }, + tasks: { data: undefined }, + proof: { data: undefined }, + review: { data: undefined }, + }); + + const { result } = renderHook(() => usePipelineStatus()); + // All incomplete when no workspace + expect(result.current.think.isComplete).toBe(false); + expect(result.current.build.isComplete).toBe(false); + expect(result.current.prove.isComplete).toBe(false); + expect(result.current.ship.isComplete).toBe(false); + }); +}); diff --git a/web-ui/src/components/layout/AppLayout.tsx b/web-ui/src/components/layout/AppLayout.tsx index 05a46ce3..85e1bcf8 100644 --- a/web-ui/src/components/layout/AppLayout.tsx +++ b/web-ui/src/components/layout/AppLayout.tsx @@ -2,6 +2,7 @@ import type { ReactNode } from 'react'; import { AppSidebar } from './AppSidebar'; +import { PipelineProgressBar } from './PipelineProgressBar'; interface AppLayoutProps { children: ReactNode; @@ -16,7 +17,10 @@ export function AppLayout({ children }: AppLayoutProps) { return (
-
{children}
+
+ + {children} +
); } diff --git a/web-ui/src/components/layout/PipelineProgressBar.tsx b/web-ui/src/components/layout/PipelineProgressBar.tsx new file mode 100644 index 00000000..793a9484 --- /dev/null +++ b/web-ui/src/components/layout/PipelineProgressBar.tsx @@ -0,0 +1,93 @@ +'use client'; + +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import { Tick01Icon } from '@hugeicons/react'; +import { cn } from '@/lib/utils'; +import { usePipelineStatus } from '@/hooks/usePipelineStatus'; + +interface Phase { + key: 'think' | 'build' | 'prove' | 'ship'; + label: string; + href: string; + paths: string[]; +} + +const PHASES: Phase[] = [ + { key: 'think', label: 'Think', href: '/prd', paths: ['/prd'] }, + { key: 'build', label: 'Build', href: '/tasks', paths: ['/tasks', '/execution', '/blockers'] }, + { key: 'prove', label: 'Prove', href: '/proof', paths: ['/proof'] }, + { key: 'ship', label: 'Ship', href: '/review', paths: ['/review'] }, +]; + +function getActivePhase(pathname: string): string | null { + for (const phase of PHASES) { + if (phase.paths.some((p) => pathname === p || pathname.startsWith(p + '/'))) { + return phase.key; + } + } + return null; +} + +export function PipelineProgressBar() { + const pathname = usePathname(); + const status = usePipelineStatus(); + + // Hide on root (workspace selector) and any path not belonging to a pipeline phase + if (pathname === '/') return null; + + const activePhase = getActivePhase(pathname); + if (!activePhase) return null; + + return ( + + ); +} diff --git a/web-ui/src/hooks/usePipelineStatus.ts b/web-ui/src/hooks/usePipelineStatus.ts new file mode 100644 index 00000000..b6288d0c --- /dev/null +++ b/web-ui/src/hooks/usePipelineStatus.ts @@ -0,0 +1,79 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import useSWR from 'swr'; +import { getSelectedWorkspacePath } from '@/lib/workspace-storage'; +import { prdApi, tasksApi, proofApi, reviewApi } from '@/lib/api'; +import type { PipelineStatus } from '@/types'; + +/** + * Aggregates pipeline phase completion from four existing API endpoints. + * Returns null SWR keys when no workspace is selected, preventing fetches. + * Uses tuple keys so each key fully encodes the request inputs (no ! assertions). + */ +export function usePipelineStatus(): PipelineStatus { + const [workspacePath, setWorkspacePath] = useState(null); + + useEffect(() => { + setWorkspacePath(getSelectedWorkspacePath()); + + const handleChange = () => setWorkspacePath(getSelectedWorkspacePath()); + window.addEventListener('storage', handleChange); + window.addEventListener('workspaceChanged', handleChange); + return () => { + window.removeEventListener('storage', handleChange); + window.removeEventListener('workspaceChanged', handleChange); + }; + }, []); + + const { data: prdData, isLoading: prdLoading, error: prdError } = useSWR( + workspacePath ? (['pipeline/prd', workspacePath] as const) : null, + ([, path]) => prdApi.getLatest(path), + { revalidateOnFocus: false } + ); + + const { data: tasksData, isLoading: tasksLoading, error: tasksError } = useSWR( + workspacePath ? (['pipeline/tasks', workspacePath] as const) : null, + ([, path]) => tasksApi.getAll(path), + { revalidateOnFocus: false } + ); + + const { data: proofData, isLoading: proofLoading, error: proofError } = useSWR( + workspacePath ? (['pipeline/proof', workspacePath] as const) : null, + ([, path]) => proofApi.getStatus(path), + { revalidateOnFocus: false } + ); + + const { data: reviewData, isLoading: reviewLoading, error: reviewError } = useSWR( + workspacePath ? (['pipeline/review', workspacePath] as const) : null, + ([, path]) => reviewApi.getDiff(path), + { revalidateOnFocus: false } + ); + + const doneMerged = tasksData + ? (tasksData.by_status.DONE ?? 0) + (tasksData.by_status.MERGED ?? 0) + : 0; + + return { + think: { + isComplete: !!prdData, + isLoading: prdLoading, + isError: !!prdError, + }, + build: { + isComplete: doneMerged > 0, + isLoading: tasksLoading, + isError: !!tasksError, + }, + prove: { + isComplete: !!proofData && proofData.total > 0 && proofData.open === 0, + isLoading: proofLoading, + isError: !!proofError, + }, + ship: { + isComplete: !!reviewData && reviewData.files_changed === 0, + isLoading: reviewLoading, + isError: !!reviewError, + }, + }; +} diff --git a/web-ui/src/types/index.ts b/web-ui/src/types/index.ts index d4ab95f1..6b28dad7 100644 --- a/web-ui/src/types/index.ts +++ b/web-ui/src/types/index.ts @@ -336,3 +336,18 @@ export interface WaiveRequest { manual_checklist: string[]; approved_by: string; } + + +// Pipeline progress types +export interface PhaseStatus { + isComplete: boolean; + isLoading: boolean; + isError: boolean; +} + +export interface PipelineStatus { + think: PhaseStatus; + build: PhaseStatus; + prove: PhaseStatus; + ship: PhaseStatus; +}