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 ( +
+ + {open && events.length > 0 && ( +
    + {events.map((e) => ( + { + onChange(e.id); + setOpen(false); + }} + /> + ))} +
+ )} +
+ ); +} + +function EventRow({ + event, + isActive, + onClick, +}: { + event: EventSummary; + isActive: boolean; + onClick: () => void; +}) { + return ( +
  • + +
  • + ); +} + +function formatDate(iso: string): string { + // Compact date-only render. Browser's locale formatter is fine for v1; + // i18n is Phase 4. Ditches the time component — operators care about + // which event, not which start time. + try { + const d = new Date(iso); + return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); + } catch { + return iso.slice(0, 10); + } +}