Files
spa/.planning/phase-2-live-map/07-event-picker.md
T
julian 05543529e4 docs(planning): file Phase 2 task specs (live monitoring map)
Nine task files matching Phase 1's shape (Goal / Deliverables / Spec /
Acceptance / Risks / Done). README updated with full sequencing diagram,
files-modified outline, tech stack additions, design rules, and phase
acceptance.

| #   | Task                                                                  |
| --- | --------------------------------------------------------------------- |
| 2.1 | MapView singleton + mapReady gate                                     |
| 2.2 | Tile-source switcher (Esri / OpenTopoMap / OSM / optional Google)     |
| 2.3 | Sprite preload — 7 racing categories x 4 colour variants              |
| 2.4 | WS client + rAF coalescer + Zustand position store + connection store |
| 2.5 | MapPositions — clustered + selected sources                           |
| 2.6 | MapTrails — bounded ring buffer, polyline rendering                   |
| 2.7 | Event picker — TanStack Query + WS subscription orchestration         |
| 2.8 | Camera control trio — default-fit / selected-follow / one-shot        |
| 2.9 | Connection status + per-device last-seen indicators                   |

Sequencing: 2.1 and 2.4 are parallel foundations (singleton vs data
pipeline). Once both land, 2.5 / 2.6 / 2.7 / 2.9 fan out independently.
2.2 / 2.3 only need 2.1. 2.8 sits at the end on top of 2.1 + 2.5.

Each task documents its deliverables down to file paths + interface
shapes, includes concrete code sketches in the Specification, lists
explicit out-of-scope items, and surfaces risks for the implementer
to think about. An agent (or future me) can pick up any single task
and ship it without re-deriving the design from the wiki.

Resolved Phase 2 design decisions baked into the task files:
- Trails: flat-colour-per-device for v1, defer speed-coloured segments
  to a Phase 3 polish task.
- Cluster params: 14/50 (traccar default); tune after seeing real data.
- Event picker placement: top-left dropdown.
- Multi-event: out — single-select, one event at a time.
- Stale-position visual: fade icon opacity; defer warning badges.
2026-05-03 09:28:16 +02:00

177 lines
7.6 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
(Filled in when the task lands.)