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:
@@ -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