564b7881bd
- 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).
216 lines
11 KiB
Markdown
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 `PENDING_SHA`.
|