Files
spa/.planning/phase-2-live-map/07-event-picker.md
T

216 lines
11 KiB
Markdown

# 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:
```ts
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:
```tsx
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.ts`** — `useActiveEventOrchestration()` 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:
```ts
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:
```tsx
<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
```ts
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
```ts
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.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 `1b5a156`.