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).
This commit is contained in:
2026-05-02 22:31:37 +02:00
parent 5cb859b403
commit 564b7881bd
8 changed files with 316 additions and 3 deletions
+2 -1
View File
@@ -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`** — `<MapTrails />` 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`** — `<TrailsToggle />` floating card top-right, below `<BasemapSwitcher />` (`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 `<TrailsToggle />` as a sibling of `<BasemapSwitcher />`, and `<MapTrails />` *before* `<MapPositions />` inside `<MapView>` so the line layer is added to the map first and renders underneath the symbol layers.
- **`src/routes/_authed/monitor.tsx`** — renders `<TrailsToggle />` as a sibling of `<BasemapSwitcher />`, and `<MapTrails />` _before_ `<MapPositions />` inside `<MapView>` 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.
+34 -1
View File
@@ -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:<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 `PENDING_SHA`.
+1 -1
View File
@@ -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) | ⬜ |
+38
View File
@@ -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<EventSummary[]> {
const directus = useDirectus();
return useQuery({
queryKey: ['events', 'user-accessible'],
queryFn: async (): Promise<EventSummary[]> => {
const result = await directus.request(
readItems<Schema, 'events', Query<Schema, EventRow>>('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,
});
}
+89
View File
@@ -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<void> {
const versionRef = useRef(0);
return useCallback(async (eventId: string | null): Promise<void> => {
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) {
// <LiveBootstrap> 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.
}
}, []);
}
+1
View File
@@ -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';
+36
View File
@@ -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
<MapView>'s wrapper.
*/}
<EventPicker
activeEventId={activeEventId}
onChange={(id) => {
void setActiveEvent(id);
}}
/>
<BasemapSwitcher />
<TrailsToggle />
{/*
+115
View File
@@ -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<HTMLDivElement>(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 (
<div ref={containerRef} className="absolute top-3 left-3 z-10">
<button
type="button"
onClick={() => setOpen((o) => !o)}
disabled={isLoading || isError || events.length === 0}
className="flex items-center gap-2 bg-background border border-border rounded-md shadow-sm px-3 py-1.5 text-sm font-medium hover:bg-accent/50 disabled:opacity-60 disabled:cursor-not-allowed"
>
<span className="truncate max-w-xs">{buttonLabel}</span>
<ChevronDown
className={cn('size-4 transition-transform', open && 'rotate-180')}
aria-hidden
/>
</button>
{open && events.length > 0 && (
<ul className="absolute mt-1 left-0 min-w-72 max-w-md max-h-80 overflow-y-auto bg-background border border-border rounded-md shadow-md">
{events.map((e) => (
<EventRow
key={e.id}
event={e}
isActive={e.id === activeEventId}
onClick={() => {
onChange(e.id);
setOpen(false);
}}
/>
))}
</ul>
)}
</div>
);
}
function EventRow({
event,
isActive,
onClick,
}: {
event: EventSummary;
isActive: boolean;
onClick: () => void;
}) {
return (
<li>
<button
type="button"
onClick={onClick}
className={cn(
'w-full text-left px-3 py-2 transition-colors border-b border-border last:border-b-0',
isActive ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/50',
)}
>
<div className="text-sm font-medium truncate">{event.name}</div>
<div className="text-xs text-muted-foreground mt-0.5">
{formatDate(event.starts_at)} · {event.discipline}
</div>
</button>
</li>
);
}
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);
}
}