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
+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);
}
}