- src/map/core/map-pref-store.ts: mapFollow boolean (default true) +
setter. Persisted via trm-map-prefs.
- src/map/core/camera/default-camera.tsx: <MapDefaultCamera /> fits
the snapshot's bounds once per activeEventId change. Uses a
fittedFor ref; resets to null when activeEventId becomes null.
Single-point events centre+zoom 12; multi-point fitBounds with
padding capped at min(canvas*0.1, 80).
- src/map/core/camera/selected-device.tsx: <MapSelectedDevice />
pans+zooms on selection change, smooth pans on subsequent position
updates with mapFollow on. Separate effect listens for user-gesture
movestart (originalEvent truthy) and flips mapFollow off.
- src/map/core/camera/map-camera.tsx: <MapCamera coordinates> imperative
one-shot. Phase 2 doesn't use it; primitive for replay (Phase 4) and
future "fit to all" UI.
- src/map/core/follow-toggle.tsx: <FollowToggle /> icon-button using
Lucide Locate/LocateOff. Sits below the trails toggle.
- src/routes/_authed/monitor.tsx: renders FollowToggle, MapDefaultCamera,
MapSelectedDevice.
Deviations:
- Manual-pan listener uses an inline { originalEvent?: unknown } type
instead of MapLibre's MapMouseEvent|MapTouchEvent union (typing
friction across version bumps).
- MapDefaultCamera clears fittedFor on activeEventId === null so
re-selecting the same event later re-fits.
Bundle: main 395KB / 120KB gz, no measurable change.
11 KiB
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: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.tsxupdated — orchestrates the picker → subscribe → snapshot → store flow: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:- Unsubscribe from the previous event's topic if any.
- Subscribe to the new event's topic.
- On
subscribed, callusePositionStore.applySnapshot(eventId, snapshot). - On error, surface a toast (or inline alert) — wrong event id, no permission, etc.
- Persist the selected event id to localStorage so reload returns to the same event.
-
src/live/index.ts— re-exportuseActiveEventOrchestration.
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:
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:
<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
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
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 buildclean./monitorshows 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
useUserEventsand Directus RLS. Directus filters server-side based on the user's permission policies. Phase 4 oftrm/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-idsurvives logout if not explicitly cleared. Update Phase 1'sperformLogoutto 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 returningEventSummary[](subset ofEventRow). 5-min stale time, sorted by-starts_at.EventSummaryis aPickof the columns the picker actually needs.src/live/active-event.ts—useActiveEventOrchestration()returns a function that swaps the active event:- Unsubscribe previous topic (best-effort).
clearForEventon the position store.subscribe('event:<new>').- On success →
applySnapshot(eventId, snapshot)+ persist tolocalStorage. - On failure →
console.warnand leave previous state alone. Out-of-order safety via per-call version counter;nullclears the active event. PlusreadSavedActiveEventId()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-exportsuseActiveEventOrchestration+readSavedActiveEventId.src/routes/_authed/monitor.tsx— orchestration:- Reads
activeEventIdfrom the position store,connectionStatusfrom the connection store. - Auto-select effect (one-shot via
initializedRef): waits forevents.dataANDconnectionStatus === 'connected', then prefers the persisted event id, else the most recent. - Renders
<EventPicker>top-left wired tosetActiveEvent.
- Reads
Deviations from spec:
- Spec referenced shadcn's
Popover+Commandprimitives. Used a vanilla<div>withuseState+ click-outsideuseEffectinstead — keeps the deps small (no extra shadcn primitive add) and the popover behaviour is dead simple. Easy to swap toPopoverlater if we want keyboard navigation / typeahead. - The auto-select effect is gated on
connectionStatus === 'connected'. Without the gate,subscribe()returns immediately withnot-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. - 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 pulllocalStorage.removeItem('trm-active-event-id')into the logout flow.
Smoke check (local pnpm dev):
/monitorshows 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-idreflects 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.