Files
brainwave/docs/superpowers/plans/2026-04-24-react-router-url-routing.md
T

24 KiB

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 <Routes> / <Route>, 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:

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:

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
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:

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
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:

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
cd frontend && npm run test:run -- src/utils/slug.test.ts

Expected: 7 passing tests.

  • Step 5: Commit
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:

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:

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(
  <StrictMode>
    <BrowserRouter>
      <App />
    </BrowserRouter>
  </StrictMode>,
)
  • Step 3: Verify build still passes
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
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:

// Change the Props interface (lines 9-15) to:
interface Props {
  exam: Exam
  state: ExamState
  setState: React.Dispatch<React.SetStateAction<ExamState>>
  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<Props>) {

// 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
cd frontend && npm run build 2>&1 | tail -5

Expected: build succeeds — onNavigate is optional so existing callers are unaffected.

  • Step 3: Commit
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:

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<SetStateAction<ExamState>>
  onSubmit: () => void
  onExit: () => void
}

export default function ExamRoute({ exam, examState, onActivate, setExamState, onSubmit, onExit }: Readonly<Props>) {
  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 (
    <ExamRunner
      exam={exam}
      state={examState}
      setState={setExamState}
      onNavigate={(i) => navigate(`/exam/${slug}/q${i + 1}`)}
      onSubmit={onSubmit}
      onExit={onExit}
    />
  )
}
  • Step 2: Verify build passes
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
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 <Routes>, 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:

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<Exam | null>(null)
  const [examState, setExamState] = useState<ExamState | null>(null)
  const [history, setHistory] = useState<HistoryEntry[]>(() => loadHistory())
  const [tweaks, setTweaks] = useState<Tweaks>(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<string, string> = { 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 = <K extends keyof Tweaks>(key: K, value: Tweaks[K]) =>
    setTweaks((t) => ({ ...t, [key]: value }))

  const setExamStateStrict: React.Dispatch<React.SetStateAction<ExamState>> = (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 (
      <div className="shell">
        <header className="topbar">
          <div className="brand">
            <span className="brand-mark" />
            <span className="brand-name">BrainWave</span>
          </div>
        </header>
        <EmailVerify onDone={handleAuthFlowDone} />
      </div>
    )
  }

  if (pathname === '/reset-password') {
    return (
      <div className="shell">
        <header className="topbar">
          <div className="brand">
            <span className="brand-mark" />
            <span className="brand-name">BrainWave</span>
          </div>
        </header>
        <ResetPassword onDone={handleAuthFlowDone} />
      </div>
    )
  }

  return (
    <div className="shell">
      <header className="topbar">
        <div className="brand">
          <span className="brand-mark" />
          <button
            className="brand-name"
            onClick={exit}
            aria-label="Home"
            style={{ background: 'none', border: 'none', padding: 0, margin: 0, cursor: 'pointer', font: 'inherit', color: 'inherit' }}
          >
            BrainWave
          </button>
          <span className="faint mono" style={{ fontSize: 11, marginLeft: 4 }}>/ practice</span>
        </div>
        <div className="topbar-meta">
          {pathname.startsWith('/exam/') && exam && (
            <>
              <span className="mono" style={{ fontSize: 12 }}>{exam.title}</span>
              <button className="btn btn-ghost btn-sm" onClick={exit}>Exit</button>
            </>
          )}
          <button className="btn btn-ghost btn-sm" onClick={() => setTweaksOpen((o) => !o)} style={{ marginLeft: 8 }} aria-label="Tweaks">⚙</button>

          {!authLoading && !user && (
            <button className="btn btn-primary btn-sm" onClick={() => setAuthModalOpen(true)}>
              Sign In
            </button>
          )}
          {!authLoading && user && (
            <div className="user-menu-wrap">
              <button
                className="user-avatar"
                onClick={() => setUserMenuOpen((o) => !o)}
                aria-label="User menu"
              >
                {user.displayName.charAt(0).toUpperCase()}
              </button>
              {userMenuOpen && (
                <div className="user-menu">
                  <div className="user-menu-header">
                    <div className="user-menu-name">{user.displayName}</div>
                    <div className="user-menu-email">{user.email}</div>
                  </div>
                  <div className="user-menu-divider" />
                  {user.role === 'admin' && (
                    <button className="user-menu-item" onClick={() => { setUserMenuOpen(false); navigate('/admin') }}>
                      Admin Dashboard
                    </button>
                  )}
                  <button className="user-menu-item" onClick={() => { setUserMenuOpen(false); logout() }}>
                    Sign Out
                  </button>
                </div>
              )}
            </div>
          )}
        </div>
      </header>

      <Routes>
        <Route path="/" element={
          <Landing onLoadExam={loadExam} history={history} onResume={resume} onClearHistory={clearAllHistory} onReviewAttempt={reviewAttempt} />
        } />
        <Route path="/exam/:slug/q:n" element={
          <ExamRoute
            exam={exam}
            examState={examState}
            onActivate={activateExam}
            setExamState={setExamStateStrict}
            onSubmit={submit}
            onExit={exit}
          />
        } />
        <Route path="/results" element={
          exam && examState
            ? <Results exam={exam} state={examState} onRestart={restart} onExit={exit} reviewOnly={false} />
            : <Navigate to="/" replace />
        } />
        <Route path="/review/:slug" element={
          exam && examState
            ? <Results exam={exam} state={examState} onRestart={restart} onExit={exit} reviewOnly={true} />
            : <Navigate to="/" replace />
        } />
        <Route path="/admin" element={
          user?.role === 'admin' ? <AdminDashboard onExit={exit} /> : <Navigate to="/" replace />
        } />
        <Route path="*" element={<Navigate to="/" replace />} />
      </Routes>

      {tweaksOpen && <TweaksPanel tweaks={tweaks} update={updateTweak} onClose={() => setTweaksOpen(false)} />}
      {authModalOpen && (
        <AuthModal
          onClose={() => setAuthModalOpen(false)}
          onForgotPassword={() => navigate('/reset-password')}
        />
      )}
    </div>
  )
}

export default function App() {
  return (
    <AuthProvider>
      <AppInner />
    </AuthProvider>
  )
}
  • Step 2: Verify the build passes
cd frontend && npm run build 2>&1 | tail -10

Expected: build succeeds with no TypeScript errors.

  • Step 3: Run all tests
cd frontend && npm run test:run

Expected: all tests pass (slugify + pre-existing utils).

  • Step 4: Commit
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:

// Delete this line:
export type AppScreen = 'landing' | 'exam' | 'results' | 'review' | 'verify' | 'reset-password' | 'admin'
  • Step 2: Verify build still passes
cd frontend && npm run build 2>&1 | tail -5

Expected: build succeeds. If there are errors referencing AppScreen, search for usages:

grep -r "AppScreen" frontend/src

Remove any remaining import of AppScreen from those files.

  • Step 3: Run all tests
cd frontend && npm run test:run

Expected: all tests pass.

  • Step 4: Commit
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
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/<slug>/q1. Navigate to question 3 (sidebar or Next button). URL should change to /exam/<slug>/q3.

  • Step 4: Verify browser back/forward

Press the browser Back button. URL should go to /exam/<slug>/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/<slug>.

  • 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 /.