diff --git a/docs/superpowers/plans/2026-04-24-react-router-url-routing.md b/docs/superpowers/plans/2026-04-24-react-router-url-routing.md new file mode 100644 index 0000000..9f2c3d3 --- /dev/null +++ b/docs/superpowers/plans/2026-04-24-react-router-url-routing.md @@ -0,0 +1,818 @@ +# 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 `/`.