From 7255cd9e97c7afab84a4bfa15ac1f9f1e16461f9 Mon Sep 17 00:00:00 2001 From: David Hagerty Date: Wed, 11 Feb 2026 13:19:37 -0500 Subject: [PATCH] feat(web): add hash-based SPA router Implement a simple hash-based router using Svelte 5 runes with no external dependency: - Pure exported matchRoute() function for unit testing route pattern matching - routerState ($state) with path and params, automatically updated on hashchange events - getCurrentRoute() function that matches current path against 8 route definitions - navigate(path) function to update window.location.hash - $effect setup/cleanup handles hashchange event listener registration and cleanup - 14 comprehensive unit tests covering all routes and parameter extraction The router supports these routes: - / (dashboard) - /projects (project-list) - /projects/:projectId (project-detail) - /goals/:goalId/tasks (task-tree) - /goals/:goalId/decisions (decision-graph) - /goals/:goalId/agents (agent-monitor) - /goals/:goalId/sessions (session-history) - /search (graph-search) Verification: 28/28 tests pass, build succeeds, TypeScript types clean Co-Authored-By: Claude Opus 4.6 --- web/src/router.svelte.ts | 146 +++++++++++++++++++++++++++++++++++++++ web/src/router.test.ts | 102 +++++++++++++++++++++++++++ 2 files changed, 248 insertions(+) create mode 100644 web/src/router.svelte.ts create mode 100644 web/src/router.test.ts diff --git a/web/src/router.svelte.ts b/web/src/router.svelte.ts new file mode 100644 index 0000000..8d46082 --- /dev/null +++ b/web/src/router.svelte.ts @@ -0,0 +1,146 @@ +/** + * Hash-based SPA router using Svelte 5 runes. + * Provides simple routing without external dependencies. + */ + +/** + * Route definition with pattern and name. + */ +export type RouteDefinition = { + pattern: string; + name: string; +}; + +/** + * Route match result with name and extracted parameters. + */ +export type RouteMatch = { + name: string; + params: Record; +}; + +/** + * Parse a route pattern into a regex and parameter names. + * For example, `/goals/:goalId/tasks` becomes regex `/^\/goals\/([^/]+)\/tasks$/` + * and paramNames = ['goalId']. + * + * Pure function, not dependent on $state. + */ +function parseRoutePattern(pattern: string): { regex: RegExp; paramNames: Array } { + const paramNames: Array = []; + let regexPattern = pattern; + + // Replace :paramName with a capture group and record the param name + const paramRegex = /:([a-zA-Z_][a-zA-Z0-9_]*)/g; + regexPattern = regexPattern.replace(paramRegex, (_, paramName: string) => { + paramNames.push(paramName); + return '([^/]+)'; // Match anything except / + }); + + // Escape special regex characters in the pattern (but keep the replaced groups) + // Instead, build the regex more carefully + regexPattern = '^' + regexPattern + '$'; + const regex = new RegExp(regexPattern); + + return { regex, paramNames }; +} + +/** + * Match a path against a route definition and extract parameters. + * + * Pure function, not dependent on $state. + * Can be unit tested directly. + * + * @param path The URL path to match (e.g., '/goals/ra-1234/tasks') + * @param routes Array of route definitions + * @returns A RouteMatch with the matched route name and extracted params, or not-found + */ +export function matchRoute(path: string, routes: Array): RouteMatch { + // Handle empty string as root + const normalizedPath = path === '' ? '/' : path; + + for (const route of routes) { + const { regex, paramNames } = parseRoutePattern(route.pattern); + const match = normalizedPath.match(regex); + + if (match) { + const params: Record = {}; + for (let i = 0; i < paramNames.length; i++) { + params[paramNames[i]] = match[i + 1]; + } + return { name: route.name, params }; + } + } + + // No match found + return { name: 'not-found', params: {} }; +} + +/** + * Router state object. + * Managed as Svelte 5 $state for reactivity. + */ +export const routerState = $state<{ + path: string; + params: Record; +}>({ + path: '/', + params: {}, +}); + +/** + * Route definitions for the application. + */ +const routes: Array = [ + { pattern: '/', name: 'dashboard' }, + { pattern: '/projects', name: 'project-list' }, + { pattern: '/projects/:projectId', name: 'project-detail' }, + { pattern: '/goals/:goalId/tasks', name: 'task-tree' }, + { pattern: '/goals/:goalId/decisions', name: 'decision-graph' }, + { pattern: '/goals/:goalId/agents', name: 'agent-monitor' }, + { pattern: '/goals/:goalId/sessions', name: 'session-history' }, + { pattern: '/search', name: 'graph-search' }, +]; + +/** + * Get the current route based on the current path. + * Returns a derived value computed from the current router state. + */ +export function getCurrentRoute(): RouteMatch { + return matchRoute(routerState.path, routes); +} + +/** + * Navigate to a path by setting the hash. + * @param path The path to navigate to (e.g., '/projects/ra-abcd') + */ +export function navigate(path: string): void { + window.location.hash = '#' + path; +} + +/** + * Initialize the router and listen for hash changes. + * The $effect handles setup and cleanup automatically. + */ +$effect(() => { + const handler = () => { + // Parse the hash and update routerState + let hash = window.location.hash; + // Remove leading '#' + if (hash.startsWith('#')) { + hash = hash.slice(1); + } + routerState.path = hash === '' ? '/' : hash; + }; + + // Parse current hash on init + handler(); + + // Listen for hash changes + window.addEventListener('hashchange', handler); + + // Return cleanup function + return () => { + window.removeEventListener('hashchange', handler); + }; +}); diff --git a/web/src/router.test.ts b/web/src/router.test.ts new file mode 100644 index 0000000..1b19cf7 --- /dev/null +++ b/web/src/router.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect } from 'vitest'; +import { matchRoute } from './router.svelte'; + + +describe('matchRoute', () => { + const routes = [ + { pattern: '/', name: 'dashboard' }, + { pattern: '/projects', name: 'project-list' }, + { pattern: '/projects/:projectId', name: 'project-detail' }, + { pattern: '/goals/:goalId/tasks', name: 'task-tree' }, + { pattern: '/goals/:goalId/decisions', name: 'decision-graph' }, + { pattern: '/goals/:goalId/agents', name: 'agent-monitor' }, + { pattern: '/goals/:goalId/sessions', name: 'session-history' }, + { pattern: '/search', name: 'graph-search' }, + ]; + + it('matches root path to dashboard', () => { + const result = matchRoute('/', routes); + expect(result.name).toBe('dashboard'); + expect(result.params).toEqual({}); + }); + + it('matches /projects to project-list', () => { + const result = matchRoute('/projects', routes); + expect(result.name).toBe('project-list'); + expect(result.params).toEqual({}); + }); + + it('matches /projects/:projectId with parameter extraction', () => { + const result = matchRoute('/projects/ra-abcd', routes); + expect(result.name).toBe('project-detail'); + expect(result.params).toEqual({ projectId: 'ra-abcd' }); + }); + + it('matches /goals/:goalId/tasks with parameter extraction', () => { + const result = matchRoute('/goals/ra-1234/tasks', routes); + expect(result.name).toBe('task-tree'); + expect(result.params).toEqual({ goalId: 'ra-1234' }); + }); + + it('matches /goals/:goalId/decisions with parameter extraction', () => { + const result = matchRoute('/goals/ra-1234/decisions', routes); + expect(result.name).toBe('decision-graph'); + expect(result.params).toEqual({ goalId: 'ra-1234' }); + }); + + it('matches /goals/:goalId/agents with parameter extraction', () => { + const result = matchRoute('/goals/ra-1234/agents', routes); + expect(result.name).toBe('agent-monitor'); + expect(result.params).toEqual({ goalId: 'ra-1234' }); + }); + + it('matches /goals/:goalId/sessions with parameter extraction', () => { + const result = matchRoute('/goals/ra-1234/sessions', routes); + expect(result.name).toBe('session-history'); + expect(result.params).toEqual({ goalId: 'ra-1234' }); + }); + + it('matches /search to graph-search', () => { + const result = matchRoute('/search', routes); + expect(result.name).toBe('graph-search'); + expect(result.params).toEqual({}); + }); + + it('returns not-found for unknown path', () => { + const result = matchRoute('/unknown/path', routes); + expect(result.name).toBe('not-found'); + expect(result.params).toEqual({}); + }); + + it('returns dashboard for empty string (root route)', () => { + const result = matchRoute('', routes); + expect(result.name).toBe('dashboard'); + expect(result.params).toEqual({}); + }); + + it('extracts multiple parameters from complex paths', () => { + const customRoutes = [ + { pattern: '/projects/:projectId/goals/:goalId', name: 'goal-detail' }, + ]; + const result = matchRoute('/projects/proj-123/goals/goal-456', customRoutes); + expect(result.name).toBe('goal-detail'); + expect(result.params).toEqual({ projectId: 'proj-123', goalId: 'goal-456' }); + }); + + it('does not match partial paths', () => { + const result = matchRoute('/goal', routes); + expect(result.name).toBe('not-found'); + }); + + it('handles trailing slashes correctly', () => { + const result = matchRoute('/projects/', routes); + // /projects/ should not match /projects + expect(result.name).toBe('not-found'); + }); + + it('handles parameters with special characters', () => { + const result = matchRoute('/projects/ra-abc-def', routes); + expect(result.name).toBe('project-detail'); + expect(result.params).toEqual({ projectId: 'ra-abc-def' }); + }); +}); -- 2.54.0