# React Router URL Routing Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Replace the custom screen-state machine in App.tsx with React Router v7 so the URL reflects the active exam and question number (e.g. `/exam/my-exam/q3`). **Architecture:** Install `react-router-dom`, wrap the app in `BrowserRouter`, replace the `screen` useState with `` / ``, and replace all `setScreen()` calls with `useNavigate()`. A new `ExamRoute` component handles deep-link restoration from localStorage and keeps `currentIdx` in sync with the `:n` URL param (enabling browser back/forward). **Tech Stack:** React 19, react-router-dom v7, Vite, TypeScript, Vitest --- ## File Map | Action | File | | ------ | ---- | | Create | `frontend/src/utils/slug.ts` | | Create | `frontend/src/utils/slug.test.ts` | | Create | `frontend/src/components/ExamRoute.tsx` | | Modify | `frontend/package.json` | | Modify | `frontend/vite.config.ts` | | Modify | `frontend/src/main.tsx` | | Modify | `frontend/src/components/ExamRunner.tsx` | | Modify | `frontend/src/App.tsx` | | Modify | `frontend/src/types.ts` | --- ## Task 1: Install react-router-dom **Files:** - Modify: `frontend/package.json` - [ ] **Step 1: Install the package** Run from the `frontend/` directory: ```bash npm install react-router-dom ``` Expected: `react-router-dom` appears under `"dependencies"` in `package.json`. No type errors. - [ ] **Step 2: Verify TypeScript types are available** Run: ```bash npm run build 2>&1 | head -5 ``` Expected: build succeeds (or fails only on pre-existing errors — not on missing `react-router-dom` types). - [ ] **Step 3: Commit** ```bash git add frontend/package.json frontend/package-lock.json git commit -m "feat: install react-router-dom" ``` --- ## Task 2: Add slugify utility (TDD) **Files:** - Create: `frontend/src/utils/slug.test.ts` - Create: `frontend/src/utils/slug.ts` - [ ] **Step 1: Write the failing tests** Create `frontend/src/utils/slug.test.ts`: ```typescript import { describe, it, expect } from 'vitest' import { slugify } from './slug' describe('slugify', () => { it('lowercases', () => { expect(slugify('My Exam')).toBe('my-exam') }) it('replaces spaces with hyphens', () => { expect(slugify('hello world')).toBe('hello-world') }) it('replaces special chars with hyphens', () => { expect(slugify('Exam: Advanced!')).toBe('exam-advanced') }) it('collapses consecutive hyphens', () => { expect(slugify('Hello World')).toBe('hello-world') }) it('trims leading and trailing hyphens', () => { expect(slugify('!hello!')).toBe('hello') }) it('preserves numbers', () => { expect(slugify('Exam 2024')).toBe('exam-2024') }) it('handles empty string', () => { expect(slugify('')).toBe('') }) }) ``` - [ ] **Step 2: Run tests to confirm they fail** ```bash cd frontend && npm run test:run -- src/utils/slug.test.ts ``` Expected: FAIL — `Cannot find module './slug'` - [ ] **Step 3: Implement slugify** Create `frontend/src/utils/slug.ts`: ```typescript export function slugify(title: string): string { return title .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/-+/g, '-') .replace(/^-|-$/g, '') } ``` - [ ] **Step 4: Run tests to confirm they pass** ```bash cd frontend && npm run test:run -- src/utils/slug.test.ts ``` Expected: 7 passing tests. - [ ] **Step 5: Commit** ```bash git add frontend/src/utils/slug.ts frontend/src/utils/slug.test.ts git commit -m "feat: add slugify utility" ``` --- ## Task 3: Update Vite config and wrap app in BrowserRouter **Files:** - Modify: `frontend/vite.config.ts` - Modify: `frontend/src/main.tsx` - [ ] **Step 1: Add historyApiFallback to Vite config** In `frontend/vite.config.ts`, add `historyApiFallback: true` to the `server` block: ```typescript import { defineConfig } from 'vitest/config' import react, { reactCompilerPreset } from '@vitejs/plugin-react' import babel from '@rolldown/plugin-babel' export default defineConfig({ plugins: [ react(), babel({ presets: [reactCompilerPreset()] }) ], server: { historyApiFallback: true, proxy: { '/api': { target: 'http://localhost:3001', changeOrigin: true, }, }, }, test: { environment: 'node', globals: true, }, }) ``` - [ ] **Step 2: Wrap the app in BrowserRouter** Replace the contents of `frontend/src/main.tsx`: ```tsx import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import { BrowserRouter } from 'react-router-dom' import 'katex/dist/katex.min.css' import './index.css' import App from './App.tsx' createRoot(document.getElementById('root')!).render( , ) ``` - [ ] **Step 3: Verify build still passes** ```bash cd frontend && npm run build 2>&1 | tail -5 ``` Expected: build succeeds — `App` does not yet use any router hooks so wrapping it is safe. - [ ] **Step 4: Commit** ```bash git add frontend/vite.config.ts frontend/src/main.tsx git commit -m "feat: wrap app in BrowserRouter and enable history fallback" ``` --- ## Task 4: Update ExamRunner with onNavigate prop **Files:** - Modify: `frontend/src/components/ExamRunner.tsx` The only change is adding an optional `onNavigate` prop and calling it from `goTo`. Everything else stays identical. - [ ] **Step 1: Add the prop and update goTo** In `frontend/src/components/ExamRunner.tsx`, update the `Props` interface and `goTo` callback: ```typescript // Change the Props interface (lines 9-15) to: interface Props { exam: Exam state: ExamState setState: React.Dispatch> onSubmit: () => void onExit: () => void onNavigate?: (i: number) => void } // Change the function signature (line 17) to: export default function ExamRunner({ exam, state, setState, onSubmit, onExit, onNavigate }: Readonly) { // Replace the goTo callback (lines 29-30) with: const goTo = useCallback((i: number) => { const next = Math.max(0, Math.min(total - 1, i)) setState((s) => ({ ...s, currentIdx: next })) onNavigate?.(next) }, [total, onNavigate]) ``` - [ ] **Step 2: Verify build passes** ```bash cd frontend && npm run build 2>&1 | tail -5 ``` Expected: build succeeds — `onNavigate` is optional so existing callers are unaffected. - [ ] **Step 3: Commit** ```bash git add frontend/src/components/ExamRunner.tsx git commit -m "feat: add optional onNavigate prop to ExamRunner" ``` --- ## Task 5: Create ExamRoute component **Files:** - Create: `frontend/src/components/ExamRoute.tsx` This component owns three responsibilities: 1. On mount, if no exam is active, attempt to restore from localStorage using the `:slug` param; redirect to `/` if no match. 2. On every render, sync the `:n` URL param to `examState.currentIdx` (enables browser back/forward). 3. Render `ExamRunner` with an `onNavigate` handler that updates the URL. - [ ] **Step 1: Create the file** Create `frontend/src/components/ExamRoute.tsx`: ```tsx import { useParams, useNavigate } from 'react-router-dom' import { useEffect } from 'react' import type { Dispatch, SetStateAction } from 'react' import ExamRunner from './ExamRunner' import { loadState } from '../utils/storage' import { slugify } from '../utils/slug' import type { Exam, ExamState } from '../types' interface Props { exam: Exam | null examState: ExamState | null onActivate: (exam: Exam, state: ExamState) => void setExamState: Dispatch> onSubmit: () => void onExit: () => void } export default function ExamRoute({ exam, examState, onActivate, setExamState, onSubmit, onExit }: Readonly) { const { slug, n } = useParams<{ slug: string; n: string }>() const navigate = useNavigate() const questionIdx = Math.max(0, (Number(n) || 1) - 1) // Deep link: restore from localStorage if no exam is active on mount useEffect(() => { if (!exam) { const saved = loadState() if (saved?.exam && slugify(saved.exam.title) === slug) { onActivate(saved.exam, { ...saved.state, currentIdx: questionIdx }) } else { navigate('/', { replace: true }) } } // Intentionally runs only on mount — dep changes after mount are handled by the sync effect below // eslint-disable-next-line react-hooks/exhaustive-deps }, []) // Sync :n URL param to currentIdx (handles browser back/forward navigation) useEffect(() => { setExamState((s) => s ? { ...s, currentIdx: questionIdx } : s) }, [questionIdx, setExamState]) if (!exam || !examState) return null return ( navigate(`/exam/${slug}/q${i + 1}`)} onSubmit={onSubmit} onExit={onExit} /> ) } ``` - [ ] **Step 2: Verify build passes** ```bash cd frontend && npm run build 2>&1 | tail -5 ``` Expected: build succeeds. `ExamRoute` is not yet imported by anything so no usages to break. - [ ] **Step 3: Commit** ```bash git add frontend/src/components/ExamRoute.tsx git commit -m "feat: add ExamRoute component with deep-link restore and back/forward sync" ``` --- ## Task 6: Migrate App.tsx to React Router **Files:** - Modify: `frontend/src/App.tsx` This is the main migration. Replace the `screen` state machine with ``, replace all `setScreen()` calls with `navigate()`, add `activateExam` for deep-link activation, and use `useLocation` for the verify/reset-password special layouts. - [ ] **Step 1: Replace App.tsx with the migrated version** Replace the entire contents of `frontend/src/App.tsx`: ```tsx import { useState, useEffect, useCallback } from 'react' import { useNavigate, useLocation, Routes, Route, Navigate } from 'react-router-dom' import type { Exam, ExamState, HistoryEntry, Tweaks } from './types' import { gradeQuestion } from './utils/grading' import { loadState, saveState, clearState, loadHistory, appendHistory, clearHistory } from './utils/storage' import { apiFetch, isAuthenticated } from './utils/api' import { slugify } from './utils/slug' import { AuthProvider, useAuth } from './contexts/AuthContext' import Landing from './components/Landing' import ExamRoute from './components/ExamRoute' import Results from './components/Results' import TweaksPanel from './components/TweaksPanel' import AuthModal from './components/AuthModal' import EmailVerify from './components/EmailVerify' import ResetPassword from './components/ResetPassword' import AdminDashboard from './components/AdminDashboard' const DEFAULT_TWEAKS: Tweaks = { theme: 'light', fontSize: 'medium', feedbackMode: 'auto' } const withNotes = (state: ExamState): ExamState => ({ ...state, notes: state.notes ?? '', }) function AppInner() { const { user, loading: authLoading, logout } = useAuth() const navigate = useNavigate() const { pathname } = useLocation() const [exam, setExam] = useState(null) const [examState, setExamState] = useState(null) const [history, setHistory] = useState(() => loadHistory()) const [tweaks, setTweaks] = useState(DEFAULT_TWEAKS) const [tweaksOpen, setTweaksOpen] = useState(false) const [authModalOpen, setAuthModalOpen] = useState(false) const [userMenuOpen, setUserMenuOpen] = useState(false) useEffect(() => { if (!authLoading && user && isAuthenticated()) { apiFetch<{ results: Array<{ id: number; title: string; score_points: number; total_points: number; correct_count: number; graded_count: number; passing_score: number; elapsed_ms: number; completed_at: number; exam_id: number | null; }> }>('/api/results') .then((data) => { const apiHistory: HistoryEntry[] = data.results.map((r) => ({ id: String(r.id), title: r.title, completedAt: r.completed_at * 1000, elapsedMs: r.elapsed_ms, gradedCount: r.graded_count, correctCount: r.correct_count, totalPoints: r.total_points, scorePoints: r.score_points, passingScore: r.passing_score, exam: { title: r.title, questions: [] } as unknown as Exam, state: { currentIdx: 0, answers: {}, flags: {}, notes: '', startedAt: 0, feedbackMode: 'end' as const } as ExamState, _remoteId: r.id, })) setHistory(apiHistory) }) .catch(() => { setHistory(loadHistory()) }) } }, [user, authLoading]) useEffect(() => { document.documentElement.dataset['theme'] = tweaks.theme const sizeMap: Record = { small: '14px', medium: '15px', large: '17px' } document.body.style.fontSize = sizeMap[tweaks.fontSize] ?? '15px' }, [tweaks]) useEffect(() => { if (pathname.startsWith('/exam/') && exam && examState) saveState({ exam, state: examState }) }, [exam, examState, pathname]) const updateTweak = (key: K, value: Tweaks[K]) => setTweaks((t) => ({ ...t, [key]: value })) const setExamStateStrict: React.Dispatch> = (nextState) => { setExamState((prev) => { if (!prev) return prev return typeof nextState === 'function' ? (nextState as (prevState: ExamState) => ExamState)(prev) : nextState }) } const activateExam = (data: Exam, state: ExamState) => { setExam(data) setExamState(withNotes(state)) } const loadExam = (data: Exam) => { const fm = tweaks.feedbackMode === 'auto' ? (data.feedbackMode ?? 'end') : tweaks.feedbackMode setExam(data) setExamState({ currentIdx: 0, answers: {}, flags: {}, notes: '', revealed: {}, startedAt: Date.now(), feedbackMode: fm }) navigate(`/exam/${slugify(data.title)}/q1`) } const resume = () => { const saved = loadState() if (saved?.exam && saved?.state) { setExam(saved.exam) setExamState(withNotes(saved.state)) navigate(`/exam/${slugify(saved.exam.title)}/q${saved.state.currentIdx + 1}`) } } const submit = useCallback(() => { if (!exam || !examState) return const completedAt = Date.now() const elapsedMs = completedAt - examState.startedAt const graded = exam.questions.map((q) => gradeQuestion(q, examState.answers[q.id])) const entry: HistoryEntry = { id: `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, title: exam.title, completedAt, elapsedMs, gradedCount: graded.filter((r) => r.status !== 'ungraded').length, correctCount: graded.filter((r) => r.correct).length, totalPoints: exam.questions.reduce((a, q) => a + (q.points ?? 1), 0), scorePoints: graded.reduce((a, r) => a + r.points, 0), passingScore: exam.passingScore ?? 60, exam, state: { ...examState, completedAt }, } if (user && isAuthenticated()) { void apiFetch('/api/results', { method: 'POST', body: JSON.stringify({ title: entry.title, scorePoints: entry.scorePoints, totalPoints: entry.totalPoints, correctCount: entry.correctCount, gradedCount: entry.gradedCount, passingScore: entry.passingScore, elapsedMs: entry.elapsedMs, notes: entry.state.notes, state: entry.state, exam: entry.exam, }), }).catch(() => { appendHistory(entry) }) } else { appendHistory(entry) } setHistory((prev) => [entry, ...prev]) clearState() setExamState((s) => s ? { ...s, completedAt } : s) navigate('/results') }, [exam, examState, user, navigate]) const exit = () => { setExam(null) setExamState(null) navigate('/') } const restart = () => { if (exam) loadExam(exam) } const reviewAttempt = (entry: HistoryEntry) => { const remoteId = (entry as HistoryEntry & { _remoteId?: number })._remoteId if (remoteId && user && isAuthenticated()) { void apiFetch<{ exam: Exam; state: ExamState; title: string; scorePoints: number; totalPoints: number; correctCount: number; gradedCount: number; passingScore: number; elapsedMs: number; completedAt: number; }>(`/api/results/${remoteId}`) .then((data) => { setExam(data.exam) setExamState(withNotes(data.state)) navigate(`/review/${slugify(entry.title)}`) }) .catch(() => { setExam(entry.exam) setExamState(withNotes(entry.state)) navigate(`/review/${slugify(entry.title)}`) }) } else { setExam(entry.exam) setExamState(withNotes(entry.state)) navigate(`/review/${slugify(entry.title)}`) } } const clearAllHistory = () => { if (confirm('Clear all past attempts?')) { clearHistory() setHistory([]) } } const handleAuthFlowDone = () => { navigate('/') setAuthModalOpen(true) } if (pathname === '/verify') { return (
BrainWave
) } if (pathname === '/reset-password') { return (
BrainWave
) } return (
/ practice
{pathname.startsWith('/exam/') && exam && ( <> {exam.title} )} {!authLoading && !user && ( )} {!authLoading && user && (
{userMenuOpen && (
{user.displayName}
{user.email}
{user.role === 'admin' && ( )}
)}
)}
} /> } /> : } /> : } /> : } /> } /> {tweaksOpen && setTweaksOpen(false)} />} {authModalOpen && ( setAuthModalOpen(false)} onForgotPassword={() => navigate('/reset-password')} /> )}
) } export default function App() { return ( ) } ``` - [ ] **Step 2: Verify the build passes** ```bash cd frontend && npm run build 2>&1 | tail -10 ``` Expected: build succeeds with no TypeScript errors. - [ ] **Step 3: Run all tests** ```bash cd frontend && npm run test:run ``` Expected: all tests pass (slugify + pre-existing utils). - [ ] **Step 4: Commit** ```bash git add frontend/src/App.tsx git commit -m "feat: migrate App.tsx to React Router — replace screen state with declarative routes" ``` --- ## Task 7: Remove AppScreen type from types.ts **Files:** - Modify: `frontend/src/types.ts` `AppScreen` is no longer referenced anywhere after the App.tsx migration. - [ ] **Step 1: Remove the type** In `frontend/src/types.ts`, delete line 116: ```typescript // Delete this line: export type AppScreen = 'landing' | 'exam' | 'results' | 'review' | 'verify' | 'reset-password' | 'admin' ``` - [ ] **Step 2: Verify build still passes** ```bash cd frontend && npm run build 2>&1 | tail -5 ``` Expected: build succeeds. If there are errors referencing `AppScreen`, search for usages: ```bash grep -r "AppScreen" frontend/src ``` Remove any remaining import of `AppScreen` from those files. - [ ] **Step 3: Run all tests** ```bash cd frontend && npm run test:run ``` Expected: all tests pass. - [ ] **Step 4: Commit** ```bash git add frontend/src/types.ts git commit -m "chore: remove unused AppScreen type" ``` --- ## Task 8: Manual smoke test No automated tests cover the React Router integration (the test environment is `node`, not jsdom). Verify the feature manually. - [ ] **Step 1: Start the dev server** ```bash cd frontend && npm run dev ``` - [ ] **Step 2: Verify landing page** Open `http://localhost:5173`. URL should be `/`. Landing page should render. - [ ] **Step 3: Start an exam and verify URL updates** Load any exam. The URL should change to `/exam//q1`. Navigate to question 3 (sidebar or Next button). URL should change to `/exam//q3`. - [ ] **Step 4: Verify browser back/forward** Press the browser Back button. URL should go to `/exam//q2` and question 2 should display. - [ ] **Step 5: Verify deep link** While on question 5, copy the URL. Open a new tab and paste it. The exam should resume at question 5 (restored from localStorage). - [ ] **Step 6: Verify submit → results URL** Submit the exam. URL should change to `/results`. - [ ] **Step 7: Verify review URL** From the landing history, click a past attempt to review. URL should change to `/review/`. - [ ] **Step 8: Verify auth flow routes** Navigate to `http://localhost:5173/verify`. The email verify screen should render (simplified header, no nav). Navigate to `http://localhost:5173/reset-password`. The reset password screen should render. - [ ] **Step 9: Verify unknown route redirect** Navigate to `http://localhost:5173/does-not-exist`. Should redirect to `/`.