Files
spa/.planning/phase-2-live-map/07-event-picker.md
T
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

7.7 KiB

Task 2.7 — Event picker (subscription driver)

Phase: 2 — Live monitoring map Status: Not started Depends on: 2.4. Wiki refs: docs/wiki/synthesis/processor-ws-contract.md §"Subscription model"; docs/wiki/synthesis/directus-schema-draft.md.

Goal

Let the operator pick which event to monitor. The picker reads the events the user has access to from Directus (via TanStack Query), drives the WS subscription via client.subscribe('event:<id>'), applies the snapshot to the position store, and switches subscriptions cleanly when the user changes events.

After this task, the live map is operator-driven — no hardcoded event id, no dev-only sample data.

Deliverables

  • src/data/events.ts — TanStack Query hook + types:

    export type EventSummary = {
      id: string;
      name: string;
      slug: string;
      discipline: 'rally' | 'time-trial' | 'regatta' | 'trail-run' | 'hike';
      starts_at: string;
      ends_at: string;
      organization_id: string;
    };
    
    export function useUserEvents(): UseQueryResult<EventSummary[]>;
    

    Fetches /items/events?fields=id,name,slug,discipline,starts_at,ends_at,organization_id&sort=-starts_at. Directus's RLS handles "what events does this user have access to" via the cookie. Stale time 5 minutes.

  • src/ui/components/event-picker.tsx<EventPicker /> component. Top-of-page dropdown showing the events list. Selected event highlights. On select, calls a callback (passed in by the monitor route).

  • src/routes/_authed/monitor.tsx updated — orchestrates the picker → subscribe → snapshot → store flow:

    function MonitorPage() {
      const activeEventId = usePositionStore((s) => s.activeEventId);
      const setActiveEvent = useActiveEventOrchestration();
      return (
        <>
          <header className="absolute top-3 left-3 z-10">
            <EventPicker activeEventId={activeEventId} onChange={setActiveEvent} />
          </header>
          <MapView>
            <MapPositions />
            <MapTrails />
            <MapDefaultCamera />
            <MapSelectedDevice />
          </MapView>
          <BasemapSwitcher />
          <ConnectionChip /> {/* 2.9 */}
        </>
      );
    }
    
  • src/live/active-event.tsuseActiveEventOrchestration() hook that wraps:

    1. Unsubscribe from the previous event's topic if any.
    2. Subscribe to the new event's topic.
    3. On subscribed, call usePositionStore.applySnapshot(eventId, snapshot).
    4. On error, surface a toast (or inline alert) — wrong event id, no permission, etc.
    5. Persist the selected event id to localStorage so reload returns to the same event.
  • src/live/index.ts — re-export useActiveEventOrchestration.

Specification

Filter for "events the user is meaningfully looking at"

The naive query GET /items/events returns every event in every org the user belongs to. For a multi-tenant pilot with one org and a handful of events, that's fine — sort by starts_at DESC and the most recent shows first.

For a future where the user is in multiple orgs with dozens of past events: filter by date window:

const oneWeekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
const url = `/api/items/events?filter[ends_at][_gte]=${oneWeekAgo}&fields=...&sort=-starts_at`;

Defaults the picker to "events that ended in the last week or are still ongoing." Document the decision; for the dogfood it's overkill but harmless.

Picker UI

A small dropdown / combobox in the top-left of the monitor page. shadcn's Popover + Command primitives (or a vanilla <select> for v1 — keep it boring).

For v1 a vanilla button + popover list is fine:

<button className="...">
  {activeEvent?.name ?? 'Select event'}
  <ChevronDown />
</button>
<Popover>
  <ul>
    {events.map((e) => (
      <li key={e.id} onClick={() => onChange(e.id)}>
        <span>{e.name}</span>
        <span className="text-xs text-muted-foreground">
          {formatDate(e.starts_at)} · {e.discipline}
        </span>
      </li>
    ))}
  </ul>
</Popover>

Active-event orchestration

export function useActiveEventOrchestration() {
  const setActiveEventInStore = usePositionStore((s) => s.applySnapshot);
  const clearStore = usePositionStore((s) => s.clearForEvent);
  const activeEventId = usePositionStore((s) => s.activeEventId);

  return useCallback(
    async (eventId: string | null) => {
      const client = getLiveClient();

      // Tear down the previous subscription.
      if (activeEventId) {
        await client.unsubscribe(`event:${activeEventId}`).catch(() => {});
        clearStore();
      }

      if (!eventId) return;

      // Subscribe to the new one.
      const result = await client.subscribe(`event:${eventId}`);
      if (!result.ok) {
        // Surface to UI (toast or inline error).
        console.warn('subscribe failed', result);
        return;
      }
      setActiveEventInStore(eventId, result.snapshot);
      localStorage.setItem('trm-active-event-id', eventId);
    },
    [activeEventId, clearStore, setActiveEventInStore],
  );
}

Auto-select on first mount

useEffect(() => {
  const saved = localStorage.getItem('trm-active-event-id');
  if (saved && events.some((e) => e.id === saved)) {
    void setActiveEvent(saved);
  } else if (events.length > 0) {
    // Auto-select the most recent.
    void setActiveEvent(events[0].id);
  }
}, [events]);

So the user lands on /monitor and the picker auto-fills with the last-used event (or the most recent). They can switch via the picker.

What this task does NOT include

  • Multi-event view. Subscribing to two events simultaneously isn't supported by v1. The picker is single-select.
  • Event creation / editing. That's the Directus admin UI's job; the SPA only consumes events.
  • Event-list filtering / search. Linear list sorted by date is enough for the dogfood. Add search if the list grows past ~20 events.
  • "All events" mode. No "everything everywhere" view. Always one event at a time.

Acceptance criteria

  • pnpm typecheck, pnpm lint, pnpm format:check, pnpm build clean.
  • /monitor shows the event picker top-left, populated with the user's accessible events.
  • On first visit, the most recent event is auto-selected; markers appear within ~1s as the snapshot arrives.
  • Switching events: the previous event's markers disappear, the new event's markers appear, no leftover state.
  • Reloading the page restores the previously selected event.
  • Selecting an event the user doesn't have permission for surfaces a clear error (the WS returns error/forbidden); the picker stays on the previous event.
  • Logging out clears the localStorage-saved event id (handled by Phase 1's logout-clears-cache flow — the event id key follows the same lifecycle).

Risks / open questions

  • useUserEvents and Directus RLS. Directus filters server-side based on the user's permission policies. Phase 4 of trm/directus (permissions) is the gate for "user X sees only events from their orgs." Until then, all authenticated users see all events. Acceptable for dogfood; Phase 4 closes it.
  • Multi-org users. Picker doesn't group by organization. With one org per pilot user, that's fine. For multi-org users (post-Phase-4), add a sub-header in the picker.
  • Event time window. Defaulted to "ended in the last week or ongoing." Operators reviewing post-event data more than a week old need to bump the window — defer until they ask.
  • Active-event persistence + logout. localStorage trm-active-event-id survives logout if not explicitly cleared. Update Phase 1's performLogout to clear it (or namespace the key under a session-scoped storage key).

Done

(Filled in when the task lands.)