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:
@@ -156,11 +156,12 @@ If operators want a longer-trail mode: a slider in the prefs (`50 / 200 / 500 /
|
||||
- **`src/map/core/map-pref-store.ts`** — extended with `trailMode: TrailMode` (`'none' | 'selected' | 'all'`, default `'selected'`) and `setTrailMode`. Persisted via the existing zustand `persist` middleware on `'trm-map-prefs'`.
|
||||
- **`src/map/layers/map-trails.tsx`** — `<MapTrails />` side-effect-only component. Single GeoJSON source + single `'line'` layer. Two-effect pattern (setup + setData on store changes). Reads `trailsByDevice`, `selectedDeviceId`, `trailMode`. Builds one `LineString` Feature per device whose trail has ≥ 2 points; filtered by mode (`none` → empty, `selected` → just the selected device, `all` → every device).
|
||||
- **`src/map/core/trails-toggle.tsx`** — `<TrailsToggle />` floating card top-right, below `<BasemapSwitcher />` (`top-32`). Three buttons (None / Selected / All); active one highlighted via `bg-accent`. Has a small "Trails" label up top.
|
||||
- **`src/routes/_authed/monitor.tsx`** — renders `<TrailsToggle />` as a sibling of `<BasemapSwitcher />`, and `<MapTrails />` *before* `<MapPositions />` inside `<MapView>` so the line layer is added to the map first and renders underneath the symbol layers.
|
||||
- **`src/routes/_authed/monitor.tsx`** — renders `<TrailsToggle />` as a sibling of `<BasemapSwitcher />`, and `<MapTrails />` _before_ `<MapPositions />` inside `<MapView>` so the line layer is added to the map first and renders underneath the symbol layers.
|
||||
|
||||
**Per-device colouring:** flat colour from a 6-entry palette (`#2563C8 / #2E8C4A / #6B46C1 / #188C8A / #C9296F / #5A5A53`) keyed by a deterministic hash of the deviceId. Same device = same colour across reloads. Distinct enough that two trails don't blend. Speed-coloured-per-segment deferred to a Phase 3 polish task per the original task spec's open-question decision.
|
||||
|
||||
**Smoke check (local `pnpm dev`):**
|
||||
|
||||
- `/monitor` shows the trails toggle below the basemap switcher.
|
||||
- Default mode is "Selected" → no trails visible until a device is selected.
|
||||
- Synthetic positions (push 2+ positions for the same deviceId via the console — `usePositionStore.getState().applyPositions(...)`) and the trail polyline appears immediately.
|
||||
|
||||
@@ -179,4 +179,37 @@ So the user lands on `/monitor` and the picker auto-fills with the last-used eve
|
||||
|
||||
## Done
|
||||
|
||||
(Filled in when the task lands.)
|
||||
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`.
|
||||
|
||||
@@ -54,7 +54,7 @@ When Phase 2 is done:
|
||||
| 2.4 | [WS client + rAF coalescer + Zustand position store](./04-ws-client-and-position-store.md) | 🟩 |
|
||||
| 2.5 | [MapPositions (clustered + selected sources)](./05-map-positions.md) | 🟩 |
|
||||
| 2.6 | [MapTrails (bounded ring buffer, polyline rendering)](./06-map-trails.md) | 🟩 |
|
||||
| 2.7 | [Event picker (subscription driver)](./07-event-picker.md) | ⬜ |
|
||||
| 2.7 | [Event picker (subscription driver)](./07-event-picker.md) | 🟩 |
|
||||
| 2.8 | [Camera control trio](./08-camera-trio.md) | ⬜ |
|
||||
| 2.9 | [Connection status + per-device last-seen indicators](./09-connection-status.md) | ⬜ |
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,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';
|
||||
|
||||
@@ -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 />
|
||||
{/*
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user