Files
spa/.planning/phase-2-live-map/07-event-picker.md
T
julian 17439de34f feat: task 2.8 camera control trio + follow toggle
- src/map/core/map-pref-store.ts: mapFollow boolean (default true) +
  setter. Persisted via trm-map-prefs.
- src/map/core/camera/default-camera.tsx: <MapDefaultCamera /> fits
  the snapshot's bounds once per activeEventId change. Uses a
  fittedFor ref; resets to null when activeEventId becomes null.
  Single-point events centre+zoom 12; multi-point fitBounds with
  padding capped at min(canvas*0.1, 80).
- src/map/core/camera/selected-device.tsx: <MapSelectedDevice />
  pans+zooms on selection change, smooth pans on subsequent position
  updates with mapFollow on. Separate effect listens for user-gesture
  movestart (originalEvent truthy) and flips mapFollow off.
- src/map/core/camera/map-camera.tsx: <MapCamera coordinates> imperative
  one-shot. Phase 2 doesn't use it; primitive for replay (Phase 4) and
  future "fit to all" UI.
- src/map/core/follow-toggle.tsx: <FollowToggle /> icon-button using
  Lucide Locate/LocateOff. Sits below the trails toggle.
- src/routes/_authed/monitor.tsx: renders FollowToggle, MapDefaultCamera,
  MapSelectedDevice.

Deviations:
- Manual-pan listener uses an inline { originalEvent?: unknown } type
  instead of MapLibre's MapMouseEvent|MapTouchEvent union (typing
  friction across version bumps).
- MapDefaultCamera clears fittedFor on activeEventId === null so
  re-selecting the same event later re-fits.

Bundle: main 395KB / 120KB gz, no measurable change.
2026-05-03 09:33:08 +02:00

11 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

Three new files plus orchestration in the monitor route:

  • src/data/events.tsuseUserEvents() TanStack Query hook returning EventSummary[] (subset of EventRow). 5-min stale time, sorted by -starts_at. EventSummary is a Pick of the columns the picker actually needs.
  • src/live/active-event.tsuseActiveEventOrchestration() returns a function that swaps the active event:
    1. Unsubscribe previous topic (best-effort).
    2. clearForEvent on the position store.
    3. subscribe('event:<new>').
    4. On success → applySnapshot(eventId, snapshot) + persist to localStorage.
    5. On failure → console.warn and leave previous state alone. Out-of-order safety via per-call version counter; null clears the active event. Plus readSavedActiveEventId() for callers that want to know what the persisted choice is.
  • src/ui/components/event-picker.tsx<EventPicker activeEventId, onChange>. Click-to-open dropdown with click-outside-to-close. Shows the events list sorted most-recent-first; each row displays name + date + discipline. Disabled state when loading / errored / empty.
  • src/live/index.ts — re-exports useActiveEventOrchestration + readSavedActiveEventId.
  • src/routes/_authed/monitor.tsx — orchestration:
    • Reads activeEventId from the position store, connectionStatus from the connection store.
    • Auto-select effect (one-shot via initializedRef): waits for events.data AND connectionStatus === 'connected', then prefers the persisted event id, else the most recent.
    • Renders <EventPicker> top-left wired to setActiveEvent.

Deviations from spec:

  1. Spec referenced shadcn's Popover + Command primitives. Used a vanilla <div> with useState + click-outside useEffect instead — keeps the deps small (no extra shadcn primitive add) and the popover behaviour is dead simple. Easy to swap to Popover later if we want keyboard navigation / typeahead.
  2. The auto-select effect is gated on connectionStatus === 'connected'. Without the gate, subscribe() returns immediately with not-connected (the subscription is still queued for replay on connect, but the snapshot path is lost). Gating means the user sees the snapshot rendered as soon as the WS is up.
  3. Logout-clears-saved-event-id: documented as a Phase 1 follow-up in this task's risks section. Not implemented here — performLogout (Phase 1.8) doesn't currently clear localStorage. A small follow-up patch will pull localStorage.removeItem('trm-active-event-id') into the logout flow.

Smoke check (local pnpm dev):

  • /monitor shows the event picker top-left. Clicking it opens the dropdown with the seeded events.
  • On a fresh page load (with stage Directus reachable + a connected WS): the dogfood event auto-selects within ~1s; the snapshot of seeded positions appears as map markers.
  • Switching events: previous event's markers disappear, new event's markers appear, no leftover state.
  • Hard refresh: the previously selected event is still active.
  • localStorage.trm-active-event-id reflects the current selection (visible in DevTools Application → Local Storage).

Bundle: main bundle 395KB / 120KB gz — small bump (~1KB) from 2.6 for the data hook and orchestration.

Landed in 1b5a156.