Commit Graph

19 Commits

Author SHA1 Message Date
julian 564b7881bd feat: task 2.7 event picker (subscription driver)
- src/data/events.ts: useUserEvents() TanStack Query (5-min stale,
  sort -starts_at). EventSummary type is a Pick of EventRow.
- src/live/active-event.ts: useActiveEventOrchestration() returns the
  swap fn — unsubscribe previous + clearForEvent + subscribe new +
  applySnapshot on success + persist to localStorage. Out-of-order
  safety via per-call version counter. Plus readSavedActiveEventId().
- src/ui/components/event-picker.tsx: <EventPicker> dropdown.
  useState + click-outside; rows show name + date + discipline.
- src/live/index.ts: re-exports active-event helpers.
- src/routes/_authed/monitor.tsx: auto-select effect (one-shot via
  initializedRef, gated on events loaded + WS connected); renders
  <EventPicker> wired to setActiveEvent.

Deviations:
1. Vanilla div + useState dropdown instead of shadcn Popover —
   no new shadcn primitive add; easy to swap later for keyboard nav.
2. Auto-select gated on connectionStatus === 'connected' so the
   subscribe call gets the snapshot path (not 'not-connected').
3. Logout-clears-saved-event-id deferred to a small Phase 1.8
   follow-up; documented in task risks.

Bundle: 395KB / 120KB gz (~1KB up from 2.6).
2026-05-03 09:32:37 +02:00
julian 28a501c02d feat: task 2.6 MapTrails (bounded ring buffer, polyline rendering)
- src/map/core/map-pref-store.ts: trailMode preference
  ('none' | 'selected' | 'all', default 'selected') + setter, persisted.
- src/map/layers/map-trails.tsx: <MapTrails /> side-effect-only.
  Single GeoJSON source + single line layer. Builds one LineString
  Feature per device whose trail has >= 2 points; filtered by mode.
  Per-device flat colour via deterministic deviceId hash mod a
  6-entry palette.
- src/map/core/trails-toggle.tsx: <TrailsToggle /> floating card
  below <BasemapSwitcher />. Three buttons (None / Selected / All).
- src/routes/_authed/monitor.tsx: renders <TrailsToggle /> +
  <MapTrails /> *before* <MapPositions /> so the line layer is added
  to the map first and renders underneath the symbol layers.

Speed-coloured-per-segment deferred to Phase 3 polish per the task
spec's open-question decision; flat-colour-per-device for v1.

Bundle: main 394KB / 120KB gz — no change from 2.5.
2026-05-03 09:31:49 +02:00
julian 25dcde093a feat: task 2.5 MapPositions (clustered + selected sources)
- 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).
2026-05-03 09:31:09 +02:00
julian 218d6b9c00 feat: task 2.4 WS client + rAF coalescer + Zustand position store
Eight files under src/live/:
- constants.ts: throughput-discipline numbers (MAX_TRAIL_LENGTH=200,
  reconnect backoff [1s/2s/4s/8s/16s, 30s ceiling], STALE_CONNECTION_MS).
- protocol.ts: zod discriminatedUnion('type', ...) for inbound
  (subscribed / unsubscribed / position / error). PositionEntry +
  SubscribeResult types per processor-ws-contract.
- connection-store.ts: Zustand store with status state machine.
- position-store.ts: Zustand store with latestByDevice + trailsByDevice
  Maps, applySnapshot/applyPositions/clearForEvent/selectDevice
  actions. Ring-buffer cap on trails; same-coordinate dedup.
- coalescer.ts: ~30-line rAF coalescer. Per-device buffer; flushes once
  per animation frame regardless of receive rate.
- ws-client.ts: state machine (idle / connecting / connected /
  reconnecting / closed) with exponential backoff, re-subscribe on
  reconnect, stale-connection check, subscribe correlation via
  pending-map + 5s timeout. URL resolution helper toAbsoluteWsUrl()
  for same-origin path inputs.
- bootstrap.tsx: <LiveBootstrap> creates the client when authenticated,
  wires positions through the coalescer to the position store, tears
  down on logout. getLiveClient() exposes the singleton for 2.7.
- index.ts: barrel re-exports.

main.tsx wraps <App /> in <LiveBootstrap> alongside <AuthBootstrap>.

Deviations:
1. Skipped the setStatus helper inside createLiveClient; conditional
   Parameters<> generics were hostile. Direct
   useConnectionStore.getState().setStatus(...) at the ~6 call sites.
2. subscribe() adds to the subscriptions Set even when not connected
   (so it replays on reconnect). Caller handles 'not-connected' by
   waiting for connection-store status transition.
3. onPosition returns an unsubscribe fn (Set-based). Multi-handler is
   free; lets future debug panels/tests attach.

Bundle: src/live/ adds ~15KB raw to the main bundle (mostly zod's
discriminated-union runtime). Total 393KB / 120KB gz.
2026-05-03 09:30:37 +02:00
julian 0b54f87860 feat: task 2.3 sprite preload (racing categories)
- src/map/assets/icons/: 9 placeholder SVGs (background plate, direction
  arrow, 7 categories: rally-car / quad / ssv / motorcycle / runner /
  hiker / default). Hand-authored simple silhouettes; replaced in
  Phase 3.8 with the branded set.
- src/map/core/categories.ts: SpriteCategory/SpriteColor types,
  mapCategoryToSprite() normaliser, inferColor() helper.
- src/map/core/sprite-preload.ts: idempotent preloadSprites() with
  memoised promise, whenSpritesReady() alias, getSpriteRegistry()
  read-only access, installSprites(map) for the mapReady flow.
  Composition pipeline draws the plate + composites a tinted icon
  centred on it (category sprites) or tints the arrow alone (direction
  sprites). Tint via canvas globalCompositeOperation: 'source-in'.
- src/main.tsx: void preloadSprites() fired at boot; the promise is
  memoised so mapReady flow awaits the same instance.
- src/map/core/map-view.tsx: onStyleData() awaits whenSpritesReady()
  AND _map.loaded() before installing sprites and flipping mapReady
  true. Sprites reinstall on every style swap.

Registry: 7 categories x 4 colours + 4 direction-only entries = 32
total. ~160KB in memory.

Deviations:
1. Direction sprites have no plate (it's a separate symbol layer in
   2.5 overlaid on the device sprite; double-plate would look wrong).
2. Hardcoded the design-system palette (#2E8C4A / #E8412B / #0E0E0C /
   #2563C8) directly. When 3.8 lands, these rebind to TRM tokens via
   CSS variables.
2026-05-03 09:30:02 +02:00
julian bc54f1e811 feat: task 2.2 tile-source switcher
- pnpm add maplibre-google-maps (runtime; lazy-imported on Google use).
- src/map/core/styles.ts: BasemapDescriptor model with four entries
  (Esri Satellite / OpenTopoMap / OSM / Google Satellite).
  Google entry gated on cfg.googleMapsKey via available(cfg).
  styleCustom() builds a single-raster-source MapLibre style with a
  glyphs URL for future label rendering.
  ensureGoogleProtocol() lazy-loads maplibre-google-maps + addProtocol
  on first use; _googleRegistered guards re-registration.
- src/map/core/map-pref-store.ts: Zustand + persist middleware
  (key: trm-map-prefs, version: 1). Holds basemapId + setBasemap.
- src/map/core/basemap-switcher.tsx: floating control top-right of
  <MapView>. Filters by available(cfg), highlights active, applies via
  getMap().setStyle(). Mount-time bootstrap (no UI state) + user-driven
  onClick (with switching state) split to satisfy React 19's
  react-hooks/set-state-in-effect rule.
- src/routes/_authed/monitor.tsx: renders <BasemapSwitcher /> as a
  child of <MapView>.
- src/vite-env.d.ts: ambient module declaration for
  maplibre-google-maps (no .d.ts ships in the package).

Deviations:
- BasemapDescriptor.style is buildStyle(cfg): StyleSpecification, not
  the spec's StyleSpecification|string union. Google needs the cfg key
  at build time; uniform function shape across all entries is cleaner.
- Bootstrap apply path doesn't use the switching UI state (no visual
  feedback needed during the silent first-paint flip from OSM to user
  preference). User-click path keeps setSwitching for the in-flight
  indicator.

Bundle: MapLibre is now its own chunk (1MB / 273KB gz), shared across
maplibre consumers. Main bundle 371KB / 114KB gz.
2026-05-03 09:29:14 +02:00
julian 87a738313e feat: task 2.1 MapView singleton + mapReady gate
- pnpm add maplibre-gl + -D @types/geojson.
- src/map/core/styles.ts: defaultStyle (OSM raster bootstrap; 2.2
  replaces with the basemap-switcher descriptor table).
- src/map/core/map-view.tsx: module-level Map singleton lazily created
  on first <MapView> mount, attached to a class="trm-map-host" detached
  <div> that React refs append/remove on mount/unmount. Style-data
  lifecycle flips mapReady false on every styledata event, polls
  loaded() at 33ms intervals, flips ready true once the style is
  loaded — the canonical MapLibre style-swap dance.
- Exports getMap()/getMapReady()/subscribeMapReady()/useMapReady (via
  useSyncExternalStore for SSR-safe + concurrent-safe reads). getMap()
  throws if called pre-mount; the explicit failure mode beats a
  null-able top-level export.
- src/routes/_authed/monitor.tsx: new /monitor route, full-viewport
  <MapView /> for 2.1 (no children — subsequent tasks plug in here).
- src/routes/_authed/index.tsx: home-page card now links to /monitor.
- eslint.config.js: override for src/map/** + src/live/** disables
  react-refresh/only-export-components. Same pattern as the existing
  overrides for shadcn primitives and route files.

Deviation: spec sketched a top-level `map` constant export; implemented
as `getMap(): MapLibreMap` (a function) so the singleton stays lazy
until <MapView> mounts. Top-level constant would either force eager
init (breaks SSR/tests) or be nullable (footgun). The function form
throws a clear error if called pre-mount.

Bundle: /monitor lazy chunk is 1MB raw / 274KB gzipped (MapLibre + CSS).
Other routes unaffected. Vite chunk-size warning is harmless.
2026-05-03 09:28:38 +02:00
julian c833d6f3dd fix(routing): redirect anonymous users from protected routes + devtools rename
Bug 1: hard-loading / while unauthenticated stayed stuck on "Loading..."
forever. Cause: the _authed layout short-circuits to the spinner when
status is anything other than 'authenticated', which means child routes
never mount. The redirect-on-anonymous useEffect lived only in the
home page (_authed/index.tsx), so it never fired — the layout's spinner
was the last thing rendered.

Fix: add a useEffect to the _authed layout component itself that
navigates to /login on the 'anonymous' transition. The layout's gate
is now: beforeLoad redirects on cold-known-anonymous, useEffect
redirects on post-mount transitions to anonymous (e.g. boot probe
resolves to anonymous after the route already rendered).

Bug 2: console warning that @tanstack/router-devtools moved to
@tanstack/react-router-devtools. Same package, renamed.

Fix: pnpm remove @tanstack/router-devtools && pnpm add -D
@tanstack/react-router-devtools; updated the lazy import in __root.tsx
to point at the new package name.

Plus: TRM_Design_System-handoff/ added to .prettierignore — those files
are immutable source material from claude.ai/design and shouldn't be
reformatted.
2026-05-02 20:00:41 +02:00
julian 8d0bc2bb1e feat(login): dev-only prefill from VITE_ADMIN_EMAIL/PASSWORD
When import.meta.env.DEV is true, the login form's email and password
fields are populated from the corresponding env vars. Shaves the manual
re-typing during dev iteration.

Production builds get empty strings regardless of build-time env values:
the prefill is gated on import.meta.env.DEV (which Vite replaces with
literal `false` at build time, so the surrounding ternary tree-shakes
and the env values can't bleed into the prod bundle even if accidentally
set in the build env).

Files:
- src/vite-env.d.ts (new): ImportMetaEnv augmentation with the four
  VITE_* vars we use (admin email/password, dev directus/processor URLs).
  Gives proper typing under strict mode.
- src/ui/pages/login.tsx: devDefaults computed once at module scope from
  import.meta.env. Form's defaultValues uses it.
- .env.example: documents VITE_ADMIN_EMAIL and VITE_ADMIN_PASSWORD with
  examples; notes the prod-ignore guarantee.
- .gitignore: adds *.env (defensive — complements the existing *.local
  pattern). .env.example stays committable (doesn't end in .env).
2026-05-02 18:48:04 +02:00
julian d3ccdded23 feat: task 1.8 logout flow + cross-tab sync + strict tsconfig
- src/auth/logout.ts: performLogout({ queryClient, navigate? })
  orchestration. Calls store.logout(), clears query cache, navigates
  to /login. Server-call failures swallowed; local state still flips
  to anonymous.
- src/auth/cross-tab-sync.ts: startCrossTabSync(). Subscribes to the
  auth store and bumps trm-auth-version in localStorage on status
  transitions; listens for storage events and re-runs initialize()
  when another tab bumps. Idempotent via module-level guard. No-ops
  if localStorage is unavailable.
- src/ui/components/logout-button.tsx: <LogoutButton> wraps shadcn
  Button. Pass-through props but locks onClick / disabled / children.
  Local isLoggingOut for "Signing out..." indicator + double-click
  guard.
- src/auth/bootstrap.tsx: <AuthBootstrap> now calls startCrossTabSync()
  alongside the existing init steps. Single mount point.
- src/auth/index.ts: re-export performLogout + startCrossTabSync.
- src/routes/_authed/index.tsx: replaced the inline sign-out button
  with <LogoutButton />. The existing useEffect on 'anonymous' status
  handles navigation when cross-tab sync fires.

Bonus: tsconfig.app.json now has "strict": true. TanStack Router emits
a conditional-type error ("strictNullChecks must be enabled") in
editors when strict is off; tsc -b was lenient. Caught in 1.7's
review. Typecheck remained green after enabling.
2026-05-02 18:46:35 +02:00
julian dc4e73f73a feat: task 1.7 routing skeleton
TanStack Router file-based routes:
- vite.config.ts: TanStackRouterVite plugin (target: react,
  autoCodeSplitting: true) before the React plugin.
- .gitignore: src/routeTree.gen.ts (auto-generated).
- src/lib/query-client.ts: module-level QueryClient singleton.
- src/routes/__root.tsx: root route, QueryClientProvider, Suspense-
  wrapped router + query devtools (dev-only via import.meta.env.DEV).
- src/routes/login.tsx: public route. validateSearch schema accepts
  optional redirect. LoginRoute wraps <LoginPage> with onAuthenticated
  navigating to redirect ?? '/'.
- src/routes/_authed/route.tsx: pathless protected layout. beforeLoad
  redirects to /login on 'anonymous'; falls through on 'unknown' /
  'authenticating' so hard reload doesn't flash the login page.
- src/routes/_authed/index.tsx: placeholder home with sign-out button.
  Cross-tab logout effect bounces to /login on store transition.
- src/App.tsx: replaced — now creates the router and renders
  <RouterProvider />. The interim status-branching logic from 1.6 is
  gone (the route tree handles it).
- eslint.config.js: override for src/routes/** disabling
  react-refresh/only-export-components — TanStack Router's file-based
  pattern intentionally co-exports Route alongside components.

Code-splitting working: login chunk 37KB, home + auth 17KB, card
primitive 31KB, all loaded lazily; main bundle 353KB.

Deviations:
1. No top-level useRequireAuth hook — per-page useEffect on store
   transitions is just as effective and only used in one place.
2. ESLint override for src/routes/** matches the existing pattern for
   src/ui/primitives/**.
3. Login page's auto-navigate effect from 1.6 is removed — the route
   wrapper owns navigation via onAuthenticated.
2026-05-02 18:45:55 +02:00
julian 4ac5ed4eca fix(auth): switch directus sdk to 'session' mode
'cookie' mode keeps the session alive via an in-memory access token
that's refreshed from a refresh cookie when the SDK is alive. After a
hard reload the SDK has no access token in memory, so /users/me 401s
before autoRefresh can kick in (or the refresh cookie's surface doesn't
cover plain reads cleanly). Net effect: every reload bounces back to
login even when the cookie is still valid.

'session' mode puts the actual session in the cookie. Browser sends it
automatically on every request, the SDK doesn't need to manage tokens,
and reload survives cleanly because /users/me with the session cookie
just works.

Reordered .with() calls to match the working pattern from a prior
project: rest() before authentication() (cosmetic; SDK accepts either
order).
2026-05-02 18:45:31 +02:00
julian ef18222edf feat: task 1.6 login page
src/ui/pages/login.tsx — LoginPage component:
- Centred card on muted background.
- shadcn Form + FormField with react-hook-form + zodResolver.
  FormMessage renders field-level zod errors automatically.
- Email + password with proper autocomplete attrs for password managers.
  autoFocus on email.
- Submit calls useAuthStore.getState().login(); errors render in a
  destructive Alert above the form.
- In-flight via the auth store's 'authenticating' status (cross-tab safe).
- onAuthenticated callback fires on store transition to authenticated;
  1.7 wires this to a router redirect.

src/App.tsx branches on auth status:
- unknown / authenticating -> "Signing you in..." placeholder
  (avoids flashing the login page on hard refresh while the boot probe
  runs)
- anonymous -> <LoginPage />
- authenticated -> home placeholder (1.7 replaces with router shell)

Deviation: skipped src/routes/login.tsx — requires the router plugin
to have generated routeTree.gen.ts, which is 1.7's territory. The page
works standalone via App.tsx's status branch.

Search-param redirect (?redirect=...) also deferred to 1.7 — no router
yet to expose useSearch. onAuthenticated callback is the seam.
2026-05-02 18:44:57 +02:00
julian a65ad428e6 fix(auth): resolve relative directus URL against window origin
The runtime config supports relative paths like /api so the SPA can run
same-origin in dev (Vite proxy) and stage/prod (Traefik). createDirectus
from @directus/sdk calls new URL(...) internally, which throws on
relative URLs. Resolve in toAbsoluteUrl() before handing to the SDK:

  '/api' -> 'http://localhost:5173/api' (dev)
  'https://api.example.com' -> passes through unchanged

Caught at runtime; typecheck/build don't surface this since the SDK
treats its url arg as a string.
2026-05-02 18:44:37 +02:00
julian 1ba0648cf3 feat: task 1.5 directus auth client + zustand auth store
Six files under src/auth/:
- client.ts: createDirectus<Schema>().with(authentication('cookie', ...))
  .with(rest(...)). Lazy singleton via initDirectusClient(url) +
  getDirectus() + useDirectus() (React-side helper). DirectusUser type;
  empty Schema (grows in Phase 2+).
- store.ts: Zustand store with discriminated AuthState
  (unknown / anonymous / authenticating / authenticated). Actions:
  initialize, login, logout, setUser. humanizeAuthError maps Directus
  error codes (INVALID_CREDENTIALS / INVALID_OTP / USER_SUSPENDED) to
  user-facing strings.
- guard.ts: useRequireAuth (redirect to /login on anonymous) and
  useRequireRole (bounce to / on role mismatch). Uses TanStack Router's
  useNavigate; effective once 1.7 wires the router.
- bootstrap.tsx: <AuthBootstrap> component runs initDirectusClient +
  initialize() once after the runtime config is loaded.
- index.ts: barrel re-exports.

main.tsx wraps App in <RuntimeConfigProvider><AuthBootstrap>...
App.tsx shows the auth status as a smoke indicator before 1.6/1.7.

Deviations:
1. Split getDirectus into initDirectusClient(url) + getDirectus()
   because the runtime config is a React context (per 1.4), not a
   Zustand store. <AuthBootstrap> bridges from React land to the non-
   React getter.
2. Added <AuthBootstrap> as a discrete component rather than putting
   the bootstrap effect in App.tsx — App.tsx gets replaced by the
   router in 1.7, but bootstrap needs to keep running.
2026-05-02 18:43:51 +02:00
julian 309333b2d3 feat: task 1.4 runtime config endpoint
Three-file config module under src/config/:
- schema.ts: RuntimeConfigSchema (directusUrl, liveWsUrl, businessWsUrl,
  optional googleMapsKey, env enum). Custom UrlOrAbsolutePath validator
  accepts http(s)/ws(s) URLs plus paths starting with /.
- load.ts: loadConfig() fetches /config.json with cache: 'no-store',
  safeParse, throws ConfigValidationError on schema failure.
- context.ts: RuntimeConfigContext + useRuntimeConfig() hook (no JSX).
- provider.tsx: RuntimeConfigProvider — loading / ready / error states.
  Single retry after 500ms on network failure; renders zod issues on
  validation failure for debuggability.

public/config.json: committed dev defaults (relative paths matching the
Vite proxy from 1.3).

src/main.tsx wraps App in <RuntimeConfigProvider>.
README gains a Runtime config section with the schema table.

Deviation: spec sketched provider+hook in one context.tsx. Split into
context.ts (hook only) and provider.tsx (component only) to satisfy
react-refresh/only-export-components without adding an eslint override.
2026-05-02 18:43:08 +02:00
julian 26e059fc20 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).
2026-05-02 18:41:54 +02:00
julian c3c83b53f6 Clean up 2026-05-02 16:56:15 +02:00
julian e76c70bfe4 Init 2026-05-02 16:53:12 +02:00