- src/data/devices.ts: useDevicesById() — TanStack Query (5-min stale)
returning Map<deviceId, Device>. deviceLabel() formats human-readable
titles ("FMB920 #3458").
- src/map/layers/map-positions.tsx: <MapPositions /> side-effect-only.
Two GeoJSON sources (clustered non-selected + unclustered selected).
Five layers: non-selected symbol + direction + cluster-bubble,
selected symbol + direction. Click handlers: marker -> selectDevice,
cluster -> getClusterExpansionZoom + easeTo. Hover toggles cursor.
- src/routes/_authed/monitor.tsx: renders <MapPositions /> inside
<MapView>.
Schema overhaul in src/auth/client.ts:
- Made Schema SDK-compatible. Each entry is an *array* of row types
(devices: DeviceRow[], not just DeviceRow). RegularCollections<Schema>
filters on array-like values; non-arrays collapse to never which
broke readItems('devices', ...) with keyof Schema = never.
- Spelled out the composed client type as DirectusClient<Schema> &
RestClient<Schema> & AuthenticationClient<Schema> — without the
explicit annotation Schema didn't flow through .with(...) chain to
request() call sites.
- Added DeviceRow + EventRow types; exported via @/auth.
useDevicesById uses readItems<Schema, 'devices', Query<Schema, DeviceRow>>
— explicit generics because the SDK doesn't infer Schema from the
receiver's type at call sites.
Deviations:
1. Spec referenced device.kind for category — Phase 1 schema doesn't
have it yet; everything maps to 'default'. Refine when kind lands.
2. Cluster bubble uses 'default-neutral' sprite instead of a dedicated
'cluster-background' (not in 2.3's registry). Swap in 3.8.
3. getClusterExpansionZoom is Promise-based in maplibre-gl 5.x (was
callback-style); used .then().
Bundle: main bundle 394KB / 120KB gz, ~1KB up from 2.4. /monitor
chunk includes the new layer module (~10KB).
trm/spa
End-user single-page app for the TRM platform: race directors, marshals, timekeepers, and (post-Phase 4 of trm/directus) public-facing participants. Talks to trm/directus for REST/GraphQL + business-plane WebSocket and to trm/processor for the live-position WebSocket firehose.
Architectural anchors live in trm/docs/wiki/:
wiki/entities/react-spa.md— this service.wiki/concepts/maps-architecture.md— map subsystem (Phase 2).wiki/synthesis/processor-ws-contract.md— live-position wire spec (Phase 2).wiki/synthesis/directus-schema-draft.md— schema this consumes.
Implementation planning lives in .planning/ — see .planning/ROADMAP.md.
Stack
- Vite 8 + React 19 + TypeScript 6 — SPA build, no SSR.
- Tailwind CSS 4 via
@tailwindcss/vite. - shadcn/ui primitives (slate base, new-york style) — copy-in components owned by us, in
src/ui/primitives/. - TanStack Router + Query — file-based routes, server-state cache.
- Zustand — high-frequency client state (auth, live positions in Phase 2).
@directus/sdk— typed Directus client (REST + WS).zod— runtime validation (config, forms, WS protocol).react-hook-form+@hookform/resolvers— forms.
Common scripts
| Command | Purpose |
|---|---|
pnpm dev |
Start the Vite dev server on :5173. |
pnpm build |
Type-check + production build into dist/. |
pnpm preview |
Serve the built dist/ for smoke testing. |
pnpm typecheck |
Type-check only (no build). |
pnpm lint |
ESLint over the repo. |
pnpm format |
Prettier auto-format. |
pnpm format:check |
Prettier check only (CI gate). |
pnpm test |
Test runner (Phase 3 wires Vitest). |
Adding a shadcn primitive
pnpm dlx shadcn@latest add <component>
Components land in src/ui/primitives/ per components.json's alias config. Edit them freely — they're our code from the moment they land.
Local dev
The Vite dev server (pnpm dev on :5173) proxies three path namespaces to local services so everything runs same-origin — the same topology stage/prod gets via Traefik, no CORS workarounds needed.
| Path | Forwards to | Notes |
|---|---|---|
/api/* |
${VITE_DEV_DIRECTUS_URL}/* |
Directus REST + GraphQL. Default http://localhost:8055. |
/ws-business |
${VITE_DEV_DIRECTUS_URL}/websocket |
Directus business-plane WebSocket (auto-derived ws:// scheme). |
/ws-live |
${VITE_DEV_PROCESSOR_WS_URL}/ |
Processor live-position WebSocket. Default ws://localhost:8081 (Phase 1.5). |
Override per-developer by copying .env.example to .env.local and editing — Vite auto-loads .env.local in any mode and the values flow into vite.config.ts via loadEnv.
If the dev port 5173 collides with another Vite app, run with VITE_PORT=5174 pnpm dev (or pin a different port in vite.config.ts).
Runtime config
/config.json is fetched at app boot, validated with zod, and held in a React context (useRuntimeConfig() hook). One image, multiple environments — the deploy stack overrides the file via a Docker volume mount; the SPA never rebuilds for an env-only change.
| Field | Type | Notes |
|---|---|---|
directusUrl |
URL/path | Directus REST + GraphQL base. Same-origin path or absolute URL. |
liveWsUrl |
URL/path | Processor live-position WebSocket URL. |
businessWsUrl |
URL/path | Directus business-plane WebSocket URL. |
googleMapsKey |
string? | Optional. If absent, Google tile sources are hidden in the UI. |
env |
enum | dev / stage / prod — used in log labels and feature-flag conditionals. |
URLs may be absolute (http(s)://, ws(s)://) or absolute paths starting with /. Relative paths work because the SPA is always same-origin to its backends. The committed public/config.json uses path-only defaults that match the dev proxy.
In stage/prod the deploy stack mounts an override file at /usr/share/nginx/html/config.json; see trm/deploy/README.md (lands in Phase 1.10).
CI
.gitea/workflows/build.yml (lands in Phase 1.9) runs the four gates — typecheck, lint, format:check, build — and publishes a Docker image on push to main.