diff --git a/.planning/phase-2-live-map/06-map-trails.md b/.planning/phase-2-live-map/06-map-trails.md
index 45f1e96..eb677b5 100644
--- a/.planning/phase-2-live-map/06-map-trails.md
+++ b/.planning/phase-2-live-map/06-map-trails.md
@@ -156,11 +156,12 @@ If operators want a longer-trail mode: a slider in the prefs (`50 / 200 / 500 /
- **`src/map/core/map-pref-store.ts`** — extended with `trailMode: TrailMode` (`'none' | 'selected' | 'all'`, default `'selected'`) and `setTrailMode`. Persisted via the existing zustand `persist` middleware on `'trm-map-prefs'`.
- **`src/map/layers/map-trails.tsx`** — `` side-effect-only component. Single GeoJSON source + single `'line'` layer. Two-effect pattern (setup + setData on store changes). Reads `trailsByDevice`, `selectedDeviceId`, `trailMode`. Builds one `LineString` Feature per device whose trail has ≥ 2 points; filtered by mode (`none` → empty, `selected` → just the selected device, `all` → every device).
- **`src/map/core/trails-toggle.tsx`** — `` floating card top-right, below `` (`top-32`). Three buttons (None / Selected / All); active one highlighted via `bg-accent`. Has a small "Trails" label up top.
-- **`src/routes/_authed/monitor.tsx`** — renders `` as a sibling of ``, and `` *before* `` inside `` so the line layer is added to the map first and renders underneath the symbol layers.
+- **`src/routes/_authed/monitor.tsx`** — renders `` as a sibling of ``, and `` _before_ `` inside `` so the line layer is added to the map first and renders underneath the symbol layers.
**Per-device colouring:** flat colour from a 6-entry palette (`#2563C8 / #2E8C4A / #6B46C1 / #188C8A / #C9296F / #5A5A53`) keyed by a deterministic hash of the deviceId. Same device = same colour across reloads. Distinct enough that two trails don't blend. Speed-coloured-per-segment deferred to a Phase 3 polish task per the original task spec's open-question decision.
**Smoke check (local `pnpm dev`):**
+
- `/monitor` shows the trails toggle below the basemap switcher.
- Default mode is "Selected" → no trails visible until a device is selected.
- Synthetic positions (push 2+ positions for the same deviceId via the console — `usePositionStore.getState().applyPositions(...)`) and the trail polyline appears immediately.
diff --git a/.planning/phase-2-live-map/07-event-picker.md b/.planning/phase-2-live-map/07-event-picker.md
index 41fbed0..a8b9809 100644
--- a/.planning/phase-2-live-map/07-event-picker.md
+++ b/.planning/phase-2-live-map/07-event-picker.md
@@ -179,4 +179,37 @@ So the user lands on `/monitor` and the picker auto-fills with the last-used eve
## Done
-(Filled in when the task lands.)
+Three new files plus orchestration in the monitor route:
+
+- **`src/data/events.ts`** — `useUserEvents()` 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.ts`** — `useActiveEventOrchestration()` returns a function that swaps the active event:
+ 1. Unsubscribe previous topic (best-effort).
+ 2. `clearForEvent` on the position store.
+ 3. `subscribe('event:')`.
+ 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`** — ``. 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 `` top-left wired to `setActiveEvent`.
+
+**Deviations from spec:**
+
+1. Spec referenced shadcn's `Popover` + `Command` primitives. Used a vanilla `
` 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 `PENDING_SHA`.
diff --git a/.planning/phase-2-live-map/README.md b/.planning/phase-2-live-map/README.md
index bc00bfc..c7dd77f 100644
--- a/.planning/phase-2-live-map/README.md
+++ b/.planning/phase-2-live-map/README.md
@@ -54,7 +54,7 @@ When Phase 2 is done:
| 2.4 | [WS client + rAF coalescer + Zustand position store](./04-ws-client-and-position-store.md) | 🟩 |
| 2.5 | [MapPositions (clustered + selected sources)](./05-map-positions.md) | 🟩 |
| 2.6 | [MapTrails (bounded ring buffer, polyline rendering)](./06-map-trails.md) | 🟩 |
-| 2.7 | [Event picker (subscription driver)](./07-event-picker.md) | ⬜ |
+| 2.7 | [Event picker (subscription driver)](./07-event-picker.md) | 🟩 |
| 2.8 | [Camera control trio](./08-camera-trio.md) | ⬜ |
| 2.9 | [Connection status + per-device last-seen indicators](./09-connection-status.md) | ⬜ |
diff --git a/src/data/events.ts b/src/data/events.ts
new file mode 100644
index 0000000..0c6d1eb
--- /dev/null
+++ b/src/data/events.ts
@@ -0,0 +1,38 @@
+import { readItems, type Query } from '@directus/sdk';
+import { useQuery, type UseQueryResult } from '@tanstack/react-query';
+import { useDirectus, type EventRow, type Schema } from '@/auth';
+
+/**
+ * The subset of `EventRow` the SPA reads when listing events. Trims the
+ * payload (no `regulation_doc_url`, `notes`) — those load on demand.
+ */
+export type EventSummary = Pick<
+ EventRow,
+ 'id' | 'name' | 'slug' | 'discipline' | 'starts_at' | 'ends_at' | 'organization_id'
+>;
+
+/**
+ * Fetch the events the current user has access to. Directus's permission
+ * policies (Phase 4 territory) determine the result set; for now all
+ * authenticated users see all events.
+ *
+ * Sorted most-recent-start-date first so the dogfood event is at the top.
+ * 5-minute stale time — events change daily at most.
+ */
+export function useUserEvents(): UseQueryResult {
+ const directus = useDirectus();
+ return useQuery({
+ queryKey: ['events', 'user-accessible'],
+ queryFn: async (): Promise => {
+ const result = await directus.request(
+ readItems>('events', {
+ fields: ['id', 'name', 'slug', 'discipline', 'starts_at', 'ends_at', 'organization_id'],
+ sort: ['-starts_at'],
+ limit: -1,
+ }),
+ );
+ return result as EventSummary[];
+ },
+ staleTime: 5 * 60 * 1000,
+ });
+}
diff --git a/src/live/active-event.ts b/src/live/active-event.ts
new file mode 100644
index 0000000..82066de
--- /dev/null
+++ b/src/live/active-event.ts
@@ -0,0 +1,89 @@
+import { useCallback, useRef } from 'react';
+import { getLiveClient } from './bootstrap';
+import { usePositionStore } from './position-store';
+
+const ACTIVE_EVENT_LS_KEY = 'trm-active-event-id';
+
+/**
+ * Returns a stored active-event id from `localStorage`, or `null`.
+ * The orchestration's caller validates that the id still resolves to a
+ * known event before passing it back in.
+ */
+export function readSavedActiveEventId(): string | null {
+ try {
+ return localStorage.getItem(ACTIVE_EVENT_LS_KEY);
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Hook returning a function that switches the active event:
+ *
+ * 1. Unsubscribes from the previous topic if any (best-effort).
+ * 2. Clears the position store of the previous event's state.
+ * 3. Subscribes to the new event topic.
+ * 4. On success → `applySnapshot(eventId, snapshot)` + persist eventId.
+ * 5. On failure → log + leave previous state alone (caller can retry).
+ *
+ * Out-of-order safety: a per-call version counter discards the result
+ * if the user kicked off another `setActiveEvent(...)` while this one
+ * was still in flight. Without it, an old subscribe's snapshot could
+ * arrive after a newer subscribe and overwrite the user's selection.
+ *
+ * Pass `null` to clear the active event without subscribing to a new
+ * one (used on logout).
+ */
+export function useActiveEventOrchestration(): (eventId: string | null) => Promise {
+ const versionRef = useRef(0);
+
+ return useCallback(async (eventId: string | null): Promise => {
+ const myVersion = ++versionRef.current;
+
+ // Tear down previous subscription, if any.
+ const prevEventId = usePositionStore.getState().activeEventId;
+ if (prevEventId) {
+ try {
+ const client = getLiveClient();
+ await client.unsubscribe(`event:${prevEventId}`);
+ } catch {
+ // Silent — even if unsubscribe fails, we drop local state.
+ }
+ usePositionStore.getState().clearForEvent();
+ }
+
+ if (eventId === null) {
+ try {
+ localStorage.removeItem(ACTIVE_EVENT_LS_KEY);
+ } catch {
+ // Ignore localStorage failures (private browsing, quota).
+ }
+ return;
+ }
+
+ let client;
+ try {
+ client = getLiveClient();
+ } catch (err) {
+ // hasn't mounted yet — caller should retry once
+ // auth resolves and the live client is up.
+ console.warn('setActiveEvent: live client not ready', err);
+ return;
+ }
+
+ const result = await client.subscribe(`event:${eventId}`);
+ if (versionRef.current !== myVersion) return; // superseded; ignore
+
+ if (!result.ok) {
+ console.warn('setActiveEvent: subscribe failed', result);
+ return;
+ }
+
+ usePositionStore.getState().applySnapshot(eventId, result.snapshot);
+ try {
+ localStorage.setItem(ACTIVE_EVENT_LS_KEY, eventId);
+ } catch {
+ // Ignore.
+ }
+ }, []);
+}
diff --git a/src/live/index.ts b/src/live/index.ts
index 70c4f7a..42c338e 100644
--- a/src/live/index.ts
+++ b/src/live/index.ts
@@ -1,4 +1,5 @@
export { LiveBootstrap, getLiveClient } from './bootstrap';
+export { readSavedActiveEventId, useActiveEventOrchestration } from './active-event';
export { createCoalescer, type Coalescer } from './coalescer';
export { useConnectionStore, type ConnectionStatus } from './connection-store';
export { usePositionStore } from './position-store';
diff --git a/src/routes/_authed/monitor.tsx b/src/routes/_authed/monitor.tsx
index 69dd394..13628a9 100644
--- a/src/routes/_authed/monitor.tsx
+++ b/src/routes/_authed/monitor.tsx
@@ -1,15 +1,45 @@
+import { useEffect, useRef } from 'react';
import { createFileRoute } from '@tanstack/react-router';
+import { useUserEvents } from '@/data/events';
+import {
+ readSavedActiveEventId,
+ useActiveEventOrchestration,
+ useConnectionStore,
+ usePositionStore,
+} from '@/live';
import { BasemapSwitcher } from '@/map/core/basemap-switcher';
import { MapView } from '@/map/core/map-view';
import { TrailsToggle } from '@/map/core/trails-toggle';
import { MapPositions } from '@/map/layers/map-positions';
import { MapTrails } from '@/map/layers/map-trails';
+import { EventPicker } from '@/ui/components/event-picker';
export const Route = createFileRoute('/_authed/monitor')({
component: MonitorPage,
});
function MonitorPage() {
+ const activeEventId = usePositionStore((s) => s.activeEventId);
+ const setActiveEvent = useActiveEventOrchestration();
+ const events = useUserEvents();
+ const connectionStatus = useConnectionStore((s) => s.status);
+ const initializedRef = useRef(false);
+
+ // Auto-select on first mount: prefer the previously-saved event id;
+ // otherwise the most recent. Gated on the WS being connected so the
+ // subscribe call doesn't immediately resolve with `not-connected`.
+ useEffect(() => {
+ if (initializedRef.current) return;
+ if (!events.data || events.data.length === 0) return;
+ if (connectionStatus !== 'connected') return;
+ initializedRef.current = true;
+
+ const saved = readSavedActiveEventId();
+ const target =
+ saved && events.data.some((e) => e.id === saved) ? saved : (events.data[0]?.id ?? null);
+ if (target) void setActiveEvent(target);
+ }, [events.data, connectionStatus, setActiveEvent]);
+
return (
// 3.5rem ≈ 56 px reserved for a future top-bar (Phase 3 chrome).
// For now the parent _authed layout has no top-bar, so the map can
@@ -20,6 +50,12 @@ function MonitorPage() {
UI controls. Floating cards positioned absolutely inside
's wrapper.
*/}
+ {
+ void setActiveEvent(id);
+ }}
+ />
{/*
diff --git a/src/ui/components/event-picker.tsx b/src/ui/components/event-picker.tsx
new file mode 100644
index 0000000..3052ebc
--- /dev/null
+++ b/src/ui/components/event-picker.tsx
@@ -0,0 +1,115 @@
+import { useEffect, useRef, useState } from 'react';
+import { ChevronDown } from 'lucide-react';
+import { useUserEvents, type EventSummary } from '@/data/events';
+import { cn } from '@/lib/utils';
+
+export type EventPickerProps = {
+ activeEventId: string | null;
+ onChange: (eventId: string) => void;
+};
+
+/**
+ * Top-left dropdown listing the events the current user can see, sorted
+ * most-recent first. Click to open; click an event to switch.
+ */
+export function EventPicker({ activeEventId, onChange }: EventPickerProps) {
+ const { data: events = [], isLoading, isError } = useUserEvents();
+ const [open, setOpen] = useState(false);
+ const containerRef = useRef(null);
+
+ // Click-outside to close.
+ useEffect(() => {
+ if (!open) return;
+ const handler = (ev: MouseEvent): void => {
+ if (containerRef.current && !containerRef.current.contains(ev.target as Node)) {
+ setOpen(false);
+ }
+ };
+ document.addEventListener('mousedown', handler);
+ return () => {
+ document.removeEventListener('mousedown', handler);
+ };
+ }, [open]);
+
+ const active = events.find((e) => e.id === activeEventId);
+
+ const buttonLabel = (() => {
+ if (active) return active.name;
+ if (isLoading) return 'Loading events…';
+ if (isError) return 'Failed to load events';
+ if (events.length === 0) return 'No events available';
+ return 'Select event';
+ })();
+
+ return (
+