feat: planning structure + task 1.2 stack rounding-out

Add .planning/ scaffolding:
- ROADMAP.md (4 phases, 8 non-negotiable design rules)
- phase-1-foundation/ README + 9 task files (1.2-1.10)
- phase-2-live-map / phase-3-dogfood-readiness / phase-4-future README placeholders

Task 1.2 — stack rounding-out:
- Tailwind 4 via @tailwindcss/vite + src/styles/globals.css
- shadcn/ui (slate, new-york) primitives in src/ui/primitives/:
  button, input, label, form, card, alert
- TanStack Router 1.169 + Query 5.100 (devtools + plugin in devDeps)
- Zustand 5, @directus/sdk 21, zod 4, react-hook-form 7 + resolvers
- Prettier 3 + eslint-config-prettier + eslint-plugin-prettier
- ESLint override disabling react-refresh/only-export-components for
  src/ui/primitives/** (intentional dual-exports in shadcn primitives)
- Path alias @/* -> ./src/* in tsconfig.json + tsconfig.app.json
  (TS 6 deprecates baseUrl; paths now resolve relative to config file).
  Pulled forward from 1.3 because shadcn add CLI needs it resolvable.
- Scripts: dev, build, preview, lint, typecheck, format, format:check,
  test (placeholder)
- App.tsx Tailwind smoke test (centred card + shadcn Button)
- README.md rewritten with stack/scripts/shadcn-add docs

All four gates green: typecheck, lint, format:check, build (222KB / 70KB gz).
This commit is contained in:
2026-05-02 17:34:34 +02:00
parent c3c83b53f6
commit 26e059fc20
34 changed files with 4868 additions and 127 deletions
+58
View File
@@ -0,0 +1,58 @@
# Phase 2 — Live monitoring map
**Status:** ⬜ Not started — depends on [[processor]] Phase 1.5 landing
The dogfood-day deliverable. After Phase 2, an operator opens the SPA, picks the active event, and watches the field move on a real-time map. Inherits the architecture documented in `docs/wiki/concepts/maps-architecture.md` from `docs/wiki/sources/traccar-maps-architecture.md`, with the deliberate divergences (rAF coalescer, Zustand, longer trail default, racing sprite set, native PostGIS GeoJSON) baked in from day one.
## Outcome statement
When Phase 2 is done:
- A `MapLibre GL JS` singleton renders inside a detached `<div>` mounted by `<MapView>`. Sources and layers are added by side-effect-only `Map*` components.
- The user can switch basemap between Esri World Imagery (satellite), OpenTopoMap, OSM raster, and (if a Google Maps key is in runtime config) Google Satellite via the `maplibre-google-maps` adapter.
- Sprite registry is preloaded at app boot: `rally-car / quad / ssv / motorcycle / runner / hiker / default` × `success / error / neutral / info`.
- The SPA opens a WebSocket to Processor (per `docs/wiki/synthesis/processor-ws-contract.md`), sends `subscribe` for the active event, receives a snapshot, then streams positions.
- An rAF-coalescer buffers incoming positions per-device; one `setData` call per frame regardless of message rate.
- `MapPositions` renders devices on two GeoJSON sources (clustered non-selected + unclustered selected). Cluster expansion and selection-on-click work.
- `MapTrails` renders a per-device bounded ring buffer of recent positions as a polyline. Default 200 points; configurable.
- An event picker (sidebar or top bar) lets the operator switch between events they have access to.
- Camera control trio (`MapCamera` / `MapDefaultCamera` / `MapSelectedDevice`) handles fit-on-load, follow-selected, and manual interaction.
- A connection-status indicator shows WS state (connected / reconnecting / offline) and last-message-received age per device (subtle UI; doesn't dominate).
## Why this is a separate phase
- **Foundation must be solid.** Auth, routing, deploy, runtime config — all must work before adding the live channel. Phase 1 ships an empty shell on stage; Phase 2 fills it.
- **Depends on the processor side.** The WS contract is locked, but the producing endpoint must exist before the SPA can connect to it. [[processor]] Phase 1.5 is the gating dependency.
- **Map architecture is non-trivial.** The singleton + side-effect-component + rAF coalescer + GeoJSON setData stack is a coherent pattern that works as a whole. Bundling it into Phase 1 would inflate Phase 1 dramatically.
## Tasks (sketched, not detailed)
These get full task files when Phase 2 starts. For now, this is the planned shape:
| # | Task | Notes |
| --- | ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
| 2.1 | MapLibre singleton + `<MapView>` + `mapReady` gate | Module-level instance, detached `<div>`, listener Set for ready transitions |
| 2.2 | Tile-source switcher | Esri / OpenTopoMap / OSM / optional Google via `maplibre-google-maps`; reads from runtime config |
| 2.3 | Sprite preload | Pre-rasterised at `devicePixelRatio` × 4 colour variants; re-`addImage`'d after every style swap |
| 2.4 | WS client + rAF coalescer + Zustand position store | The throughput-discipline core. Per-device latest-position map + per-device bounded trail ring buffer |
| 2.5 | `MapPositions` (clustered + selected sources) | Symbol layer + direction arrow + cluster bubble; selection on click |
| 2.6 | `MapTrails` (bounded ring buffer, polyline rendering) | Default 200 points/device; possibly speed-coloured per segment (post-decision) |
| 2.7 | Event picker + sidebar | Reads events from Directus REST via TanStack Query; subscribes / unsubscribes the WS on switch |
| 2.8 | Camera control trio | One-shot fit / initial framing / reactive follow |
| 2.9 | Connection-status + per-device last-seen indicators | Small chrome elements; non-dominant |
## Architectural boundaries to maintain
- `src/map/` is a self-contained module. Imports `@/auth` (for the WS cookie) and `@/config` (for the runtime config), nothing else. The map subsystem must be deletable as a unit if we ever need to reroute (we won't).
- `src/live/` houses the WS client, position store, and the coalescer. Decoupled from the map module so the map renders whatever's in the store, regardless of source.
- No domain logic. The map shows positions; it doesn't know about classes, entries, penalties, or stages. Phase 2.5+ is when domain awareness lands.
## Open questions blocking task-level detail
(These get answered when Phase 2 starts.)
1. **Live trail colouring.** Flat colour by device or speed-coloured per segment (Traccar's replay style)? Speed-coloured live is novel and informative for race operators; needs a decision before 2.6.
2. **Event-picker placement.** Top bar (always visible) or sidebar (collapsible)? Depends on the rest of the operator chrome.
3. **Cluster parameters.** Traccar uses `clusterMaxZoom: 14, clusterRadius: 50`. Rally density (50500 vehicles spread across a country-scale stage) may want different values. Defer until we see real positions on the map.
4. **Per-device sidebar list.** Out of scope for v1 of Phase 2 or in scope? Leaning out — the map is the focus; a list is supplementary.
5. **What happens when the user has access to multiple events.** Picker shows all? Only the active ones (between `starts_at` and `ends_at`)? Decide before 2.7.