- Dockerfile: three-stage (deps / build / runtime). deps stage runs pnpm fetch with BuildKit cache mount; build stage runs vite build to produce dist/; runtime stage is nginx:1.27-alpine serving the bundle. HEALTHCHECK via wget against localhost. - nginx.conf: gzip on text assets; /assets/ long-cache (hashed filenames immutable); /config.json no-cache (volume-mountable override in stage/prod); /index.html no-cache; SPA routing fallback via try_files ... /index.html. - .dockerignore: keeps the context small (node_modules, dist, env, .git, .gitea, .planning, *.md except README, .claude, .vscode). - .gitea/workflows/build.yml: matches trm/processor shape with format:check added between lint and test. Path filter excludes .planning and pure-markdown changes. Steps: checkout, Node 22, pnpm@latest-9, install --frozen-lockfile, typecheck, lint, format:check, test, buildx, registry login, build & push trm/spa:main, Portainer webhook. Deviations from spec: - Push :main tag only (not :main + per-commit SHA). Matches the other repos; SHA-pinning happens via *_TAG env vars in trm/deploy. SHA tagging is a cross-repo refactor for later. - Pin pnpm@latest-9 (matching existing repos), not pnpm@latest from the spec. Reproducibility win for CI. Smoke: typecheck/lint/format:check/build all green locally. Local docker build not run (Docker unavailable on this machine); CI is the gate. Required for first deploy (1.10 covers the rest): - REGISTRY_USERNAME / REGISTRY_PASSWORD / PORTAINER_WEBHOOK_URL secrets in the Gitea repo settings. - SPA service block in trm/deploy/compose.yaml.
7.9 KiB
spa — Roadmap
A React + TypeScript single-page application for end-user operators of the TRM platform: race directors, marshals, timekeepers, and (post-Phase 4 of directus) public-facing participants. Talks to directus for REST/GraphQL + business-plane WebSocket and to processor for the live-position WebSocket firehose.
This file is the single navigation hub for all SPA implementation planning. Each phase has its own folder with a README and granular task files. Update statuses here as work lands.
Status legend
| Symbol | Meaning |
|---|---|
| ⬜ | Not started |
| 🟦 | Planned (designed, not coded) |
| 🟨 | In progress |
| 🟩 | Done |
| ⏸ | Paused / blocked |
| ❄️ | Frozen / future / optional |
Architectural anchors
The SPA is specified by the wiki at ../docs/wiki/. Implementing agents should read these pages before starting any task:
- This service —
docs/wiki/entities/react-spa.md - Map subsystem —
docs/wiki/concepts/maps-architecture.md,docs/wiki/sources/traccar-maps-architecture.md - Live channel —
docs/wiki/concepts/live-channel-architecture.md,docs/wiki/synthesis/processor-ws-contract.md - Auth + business plane —
docs/wiki/entities/directus.md,docs/wiki/synthesis/directus-schema-draft.md - System architecture —
docs/wiki/sources/gps-tracking-architecture.md,docs/wiki/concepts/plane-separation.md
Non-negotiable design rules
These rules govern every task. Any deviation must be discussed and documented as a decision before code lands.
- Singleton MapLibre. A single
maplibregl.Mapinstance lives in a module-level variable, attached to a detached<div>. React mounts/unmounts the div via refs across page navigation. WebGL context never recreates. Pattern is documented indocs/wiki/concepts/maps-architecture.md. - Side-effect-only
Map*components. Every map-participating component returnsnulland usesuseEffectfor setup + cleanup, with a separate effect forsetDataupdates. No DOM marker components, noreact-map-gl. - rAF coalescer at the WS boundary. Position messages buffer per-device; one
requestAnimationFrametick flushes the latest snapshot to the Zustand store. Per-message dispatch is the failure modetraccar-webexhibits — we don't replicate it. - Same-origin everything. SPA bundle, directus REST/WS, and processor WS all served under one origin via reverse proxy. Vite dev proxy in dev; Traefik in stage/prod. Cross-origin breaks the cookie auth flow.
- In-memory access token, httpOnly refresh cookie. Access token never touches
localStorage. Refresh cookie ishttpOnly + Secure + SameSite=Lax. Refresh on 401 via Directus's/auth/refresh. - Role-aware UI shape from day one. Even though everyone's admin until directus Phase 4, the route guards and conditional rendering check
user.rolerather than hard-coding "everyone sees everything." Retrofitting is painful; baking it in costs nothing. - Runtime config, not build-time. Per-environment values (Directus URL, processor WS URL, optional Google Maps key) come from
/config.jsonserved by the proxy. One image, multiple environments. No rebuild to switch envs. - Native PostGIS GeoJSON. Geometry round-trips through
ST_AsGeoJSON(geometry)on the server, not WKT. The SPA never importswellknownor runs WKT parsing in the client.
Phases
Phase 1 — Foundation
Status: 🟨 In progress (1.1 done)
Outcome: A deployable empty-shell SPA: scaffold + stack rounded out + Directus cookie auth flow + protected routes + Gitea CI + compose deploy block. End state: an operator can browse to https://stage.trmtracking.org, log in with a Directus credential, see a placeholder home page, log out. No live map yet — that's Phase 2.
See phase-1-foundation/README.md
| # | Task | Status | Landed in |
|---|---|---|---|
| 1.1 | Project scaffold (Vite + React + TS) | 🟩 | (manual) |
| 1.2 | Stack rounding-out (Tailwind + shadcn/ui + deps + Prettier) | 🟩 | 9918418 |
| 1.3 | Vite dev proxy + path aliases + tsconfig hardening | 🟩 | 39b60c9 |
| 1.4 | Runtime config endpoint | 🟩 | 8e2151a |
| 1.5 | Directus auth client (cookie mode + refresh) | 🟩 | 38fe2e3 |
| 1.6 | Login page | 🟩 | 7215cb5 |
| 1.7 | Routing skeleton (TanStack Router + role-aware guards) | 🟩 | f4a5e5b |
| 1.8 | Logout flow | 🟩 | 1ee339c |
| 1.9 | Gitea CI + Dockerfile + nginx static serve | 🟩 | PENDING_SHA |
| 1.10 | Compose service block in trm/deploy | ⬜ | — |
Phase 2 — Live monitoring map
Status: ⬜ Not started — depends on processor Phase 1.5 landing
Outcome: The dogfood-day deliverable. MapLibre singleton + tile-source switcher + sprite preload + WS client with rAF coalescer + Zustand position store + MapPositions (clustered + selected) + MapTrails (bounded ring buffer) + event picker + camera control trio + connection-status indicator. Operators on race day open one page, pick the active event, and watch their field move in real time.
See phase-2-live-map/README.md
Phase 3 — Dogfood readiness
Status: ⬜ Not started Outcome: Operational polish a closed pilot needs but a demo doesn't: error boundaries that don't blank-screen the map, connection-state UI that tells the operator "WS reconnecting", mobile-responsive baseline (doesn't crash on a phone in the field), per-device detail panel, operator-friendly empty/loading states.
See phase-3-dogfood-readiness/README.md
Phase 4 — Future / optional
Status: ❄️ Not committed
See phase-4-future/README.md
Ideas on radar: geometry editor (depends on directus Phase 2 collections), replay mode, heatmaps / deck.gl, i18n (Albanian), dark mode, E2E tests.
Operating model
- Implementation agent contract. Each task file is self-sufficient: goal, deliverables, specification, acceptance criteria. An agent should be able to complete one task without reading the whole wiki — but should skim the wiki references at the top of the task before starting.
- Sequence within a phase. Task numbering reflects intended order. Soft dependencies are explicit in each task's "Depends on" field. Tasks with no dependencies on each other can be done in parallel.
- Status updates. When a task is started, change its row in this ROADMAP to 🟨 and the task file's status badge accordingly. When done, 🟩 + a one-line note in the task file's "Done" section pointing at the merging commit/PR.
- Drift control. If implementation diverges from a task's spec, update the task file before the diverging code lands, with a note explaining why. Do not let plans rot.