Compare commits
15 Commits
28a501c02d
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a93a54b60 | |||
| 498c0afaca | |||
| 32a7660ab4 | |||
| b80e596080 | |||
| 049e74a427 | |||
| 64b3291dd3 | |||
| bc626a5960 | |||
| 89086ef53e | |||
| ed88f1767d | |||
| 18d893f47a | |||
| 17439de34f | |||
| cc4cd8ddbf | |||
| 564b7881bd | |||
| 5cb859b403 | |||
| e2ea4e6c08 |
@@ -2,7 +2,7 @@ name: Build and Push spa
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [dev]
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'public/**'
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
- name: Login to Gitea Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.dev.microservices.al
|
||||
registry: git.dev.trmtracking.org
|
||||
username: ${{ secrets.REGISTRY_USERNAME }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
|
||||
@@ -67,8 +67,18 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: git.dev.microservices.al/trm/spa:main
|
||||
tags: git.dev.trmtracking.org/trm/spa:dev
|
||||
|
||||
- name: Trigger Portainer Deploy
|
||||
- name: Trigger Komodo Stack redeploy
|
||||
if: success()
|
||||
run: curl -X POST "${{ secrets.PORTAINER_WEBHOOK_URL }}"
|
||||
env:
|
||||
URL: ${{ secrets.KOMODO_STACK_WEBHOOK_URL }}
|
||||
SECRET: ${{ secrets.KOMODO_WEBHOOK_SECRET }}
|
||||
run: |
|
||||
body='{"ref":"refs/heads/dev"}'
|
||||
sig=$(printf '%s' "$body" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
|
||||
curl -fsS -X POST \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H "X-Hub-Signature-256: sha256=$sig" \
|
||||
-d "$body" \
|
||||
"$URL"
|
||||
|
||||
@@ -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.
|
||||
@@ -171,4 +172,4 @@ If operators want a longer-trail mode: a slider in the prefs (`50 / 200 / 500 /
|
||||
|
||||
**Bundle:** main bundle 394KB / 120KB gz — no change from 2.5. Trails layer is small.
|
||||
|
||||
Landed in `PENDING_SHA`.
|
||||
Landed in `510dfdf`.
|
||||
|
||||
@@ -179,4 +179,38 @@ 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 `1b5a156`.
|
||||
|
||||
@@ -191,4 +191,30 @@ Click toggles `mapFollow`. Selecting a device with follow off still pans to it o
|
||||
|
||||
## Done
|
||||
|
||||
(Filled in when the task lands.)
|
||||
- **`src/map/core/map-pref-store.ts`** — added `mapFollow: boolean` (default `true`) + `setMapFollow`. Persisted via the existing `trm-map-prefs` zustand+persist store.
|
||||
- **`src/map/core/camera/default-camera.tsx`** — `<MapDefaultCamera />`. Watches `activeEventId` + `latestByDevice`. Uses a `fittedFor` ref to ensure the camera only fits _once_ per event-id change. Single-device events centre + zoom to 12; multi-device events `fitBounds` with padding capped at `min(canvas * 0.1, 80)`.
|
||||
- **`src/map/core/camera/selected-device.tsx`** — `<MapSelectedDevice />`. Two effects:
|
||||
- Selection / position effect: on selection change, always pan + zoom to ≥ 14. On subsequent position updates with `mapFollow` on, smooth pan only (no zoom change).
|
||||
- Manual-pan listener: hooks `map.on('movestart', ...)`. Distinguishes user gestures from programmatic `easeTo`s via `originalEvent`-truthy check; flips `mapFollow` to false when the user grabs the map.
|
||||
- **`src/map/core/camera/map-camera.tsx`** — `<MapCamera coordinates>` props-driven one-shot. Re-runs on `coordinates` reference change. Phase 2 doesn't use it; reserved for replay (Phase 4) and "fit to all" UI (Phase 3+).
|
||||
- **`src/map/core/camera/index.ts`** — barrel.
|
||||
- **`src/map/core/follow-toggle.tsx`** — `<FollowToggle />` icon-button using Lucide's `Locate` / `LocateOff`. Sits at `top-56 right-3` below the trails toggle. `aria-pressed` reflects the state for keyboard users.
|
||||
- **`src/routes/_authed/monitor.tsx`** — renders `<FollowToggle />`, `<MapDefaultCamera />`, and `<MapSelectedDevice />` (the camera components after the layer components so they react to whatever positions just got rendered).
|
||||
|
||||
**Deviations from spec:**
|
||||
|
||||
- Spec sketched the manual-pan listener inside `<MapSelectedDevice>` as a separate effect with a typed `MapMouseEvent | MapTouchEvent` union. MapLibre's `'movestart'` handler typing was awkward across version bumps; defined a tiny inline `{ originalEvent?: unknown }` type and casted there. Functionally equivalent; less type-import friction.
|
||||
- `<MapDefaultCamera>` clears `fittedFor.current = null` when `activeEventId` becomes null (e.g. on logout / event clear). Without that, switching back to the same event later wouldn't re-fit. Spec didn't call this out; added it as a small correctness fix.
|
||||
|
||||
**Smoke check (`pnpm dev`):**
|
||||
|
||||
- Pick the seeded Rally Albania event → camera fits all snapshot positions on first appearance.
|
||||
- Click a device → camera pans + zooms in.
|
||||
- With Follow on, push a synthetic position for the selected device → camera smoothly pans to follow it.
|
||||
- Pan the map manually → Follow toggle visibly flips off; camera stops following.
|
||||
- Click Follow back on → camera resumes following on the next position update.
|
||||
- Switch events → camera fits the new event's snapshot once.
|
||||
|
||||
**Bundle:** main 395KB / 120KB gz — no measurable change (camera components are tiny).
|
||||
|
||||
Landed in `259d1e9`.
|
||||
|
||||
@@ -147,4 +147,29 @@ useEffect(() => {
|
||||
|
||||
## Done
|
||||
|
||||
(Filled in when the task lands.)
|
||||
- **`src/live/last-seen.ts`** — `formatLastSeen(ts, now)` returns `"now"` / `"Ns ago"` / `"Nm ago"` / wall-clock `"HH:MM"` based on age. Used by both the connection chip and Phase 3.4's per-device detail panel.
|
||||
- **`src/live/use-staleness.ts`** — `useStalenessTick(intervalMs = 1000)` hook. Re-renders subscribers every `intervalMs` with the current epoch ms. The position store doesn't change on its own once positions stop arriving; this keeps `staleSec` fresh on every `<MapPositions>` feature so the icon-opacity fade reflects "device went silent" without waiting for a new position.
|
||||
- **`src/ui/components/connection-chip.tsx`** — `<ConnectionChip />` floating bottom-left. Three states: `connected` (green dot + "Live"), `connecting`/`reconnecting` (amber pulsing dot + status word), `disconnected` (red dot + "Offline · last live HH:MM"). `aria-live="polite"` so screen readers announce transitions; `role="status"`.
|
||||
- **`src/map/layers/map-positions.tsx`** updated:
|
||||
- Adds `staleSec` to every `FeatureProps`; computed as `Math.floor((now - position.ts) / 1000)`.
|
||||
- Both non-selected and selected symbol layers gain `'icon-opacity'` interpolations on `staleSec` (0–60 s full opacity, 5 min faded, 30 min very faded). Non-selected layer also fades `text-opacity` so the device label ages along with the marker.
|
||||
- Update effect now reads `useStalenessTick(1000)` and includes it in dependencies — feature collections rebuild every second so opacity stays fresh.
|
||||
- **`src/routes/_authed/monitor.tsx`** — renders `<ConnectionChip />` inside `<MapView>`.
|
||||
- **`src/live/index.ts`** — re-exports `formatLastSeen` and `useStalenessTick`.
|
||||
|
||||
**Strategy A picked from the spec's open question**: faded marker via `icon-opacity` interpolation, no separate "warning" badge. Rationale per spec: simpler, fewer layers, cleaner visually. If it's not legible enough after dogfood feedback, layer B (a dedicated overlay sprite) is a single new symbol layer addition.
|
||||
|
||||
**Deviations from spec:**
|
||||
|
||||
1. Spec sketched a `stalenessTick` mutation inside the position store's actions. Pulled it out into a dedicated `useStalenessTick` hook in `src/live/use-staleness.ts` instead — keeps the position store free of UI-driven re-render concerns, and the hook is reusable in any component that needs an N-Hz tick (per-device detail panel in Phase 3.4 will).
|
||||
2. Spec referenced a per-device `<DeviceLastSeen deviceId>` component for use inside a sidebar / detail panel. Skipped — the SPA doesn't have a sidebar yet (Phase 3.4 territory), and the per-marker fade is the per-device indicator for v1. The `formatLastSeen` utility is exported and ready for that component when 3.4 lands.
|
||||
|
||||
**Smoke check (`pnpm dev`):**
|
||||
- `/monitor` shows the connection chip bottom-left. While connecting the chip pulses amber. Once the WS opens, it flips to a green dot + "Live". Disconnect the network → flips to amber + "Reconnecting…". Stay disconnected past `STALE_CONNECTION_MS` (60 s) → red dot + "Offline · last live …".
|
||||
- Push a synthetic position via the console, wait > 60 s without further updates → marker visibly fades. Wait > 5 min → fades further. Each tier matches the interpolation breakpoints.
|
||||
- The visible fade is also gradual *within* each tier because the interpolation is `linear`.
|
||||
- Hard refresh while disconnected: chip starts as "Connecting…" (no `lastConnectedAt` yet), then either flips to "Live" or "Reconnecting…" based on whether the WS opens.
|
||||
|
||||
**Bundle:** main 396KB / 121KB gz — small bump from 2.8.
|
||||
|
||||
Landed in `bb2a399`.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Phase 2 — Live monitoring map
|
||||
|
||||
**Status:** ⬜ Not started — depends on [[processor]] Phase 1.5 landing (already shipped).
|
||||
**Status:** 🟩 Done — all 9 tasks shipped.
|
||||
|
||||
The dogfood-day deliverable. After Phase 2, an operator opens the SPA, picks the active event, and watches the field move on a real-time map. Inherits the architecture documented in `docs/wiki/concepts/maps-architecture.md` from `docs/wiki/sources/traccar-maps-architecture.md`, with the deliberate divergences (rAF coalescer, Zustand, longer trail default, racing sprite set, native PostGIS GeoJSON) baked in from day one.
|
||||
|
||||
@@ -53,10 +53,10 @@ When Phase 2 is done:
|
||||
| 2.3 | [Sprite preload (racing categories)](./03-sprite-preload.md) | 🟩 |
|
||||
| 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.8 | [Camera control trio](./08-camera-trio.md) | ⬜ |
|
||||
| 2.9 | [Connection status + per-device last-seen indicators](./09-connection-status.md) | ⬜ |
|
||||
| 2.6 | [MapTrails (bounded ring buffer, polyline rendering)](./06-map-trails.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) | 🟩 |
|
||||
|
||||
## Files modified
|
||||
|
||||
|
||||
+3
-1
@@ -14,4 +14,6 @@ src/routeTree.gen.ts
|
||||
# Design handoff bundle — immutable source material from claude.ai/design.
|
||||
# Do not auto-format; preserve as-shipped for reference.
|
||||
TRM_Design_System-handoff
|
||||
|
||||
.planning
|
||||
.gitea
|
||||
.tanstack
|
||||
@@ -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,7 +1,10 @@
|
||||
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 { formatLastSeen } from './last-seen';
|
||||
export { usePositionStore } from './position-store';
|
||||
export { useStalenessTick } from './use-staleness';
|
||||
export {
|
||||
PositionEntrySchema,
|
||||
InboundMessageSchema,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Format a position-recorded timestamp as a human-readable "age" string.
|
||||
*
|
||||
* - < 5 s → "now"
|
||||
* - < 60 s → "Ns ago"
|
||||
* - < 1 hr → "Nm ago"
|
||||
* - else → wall-clock time ("HH:MM")
|
||||
*
|
||||
* Used by the connection chip ("last live HH:MM") and by Phase 3.4's
|
||||
* per-device detail panel.
|
||||
*/
|
||||
export function formatLastSeen(ts: number, now: number = Date.now()): string {
|
||||
const ageMs = Math.max(0, now - ts);
|
||||
if (ageMs < 5_000) return 'now';
|
||||
if (ageMs < 60_000) return `${Math.floor(ageMs / 1000)}s ago`;
|
||||
if (ageMs < 60 * 60_000) return `${Math.floor(ageMs / 60_000)}m ago`;
|
||||
const d = new Date(ts);
|
||||
return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Re-renders subscribers every `intervalMs` with the current epoch ms.
|
||||
*
|
||||
* Used by `<MapPositions>` to inject a fresh `staleSec` property on
|
||||
* every feature so the symbol layer's `icon-opacity` interpolation can
|
||||
* fade markers whose last position is getting old. Without this, the
|
||||
* fade only updates when a new position arrives — which is exactly what
|
||||
* we don't want for "this device went silent" UX.
|
||||
*
|
||||
* Default 1 Hz. The cost is one rebuild of the position FeatureCollection
|
||||
* per second; with N devices it's O(N) — fine at pilot scale.
|
||||
*/
|
||||
export function useStalenessTick(intervalMs: number = 1000): number {
|
||||
const [tick, setTick] = useState<number>(() => Date.now());
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
setTick(Date.now());
|
||||
}, intervalMs);
|
||||
return () => {
|
||||
clearInterval(id);
|
||||
};
|
||||
}, [intervalMs]);
|
||||
return tick;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { LngLatBounds } from 'maplibre-gl';
|
||||
import { usePositionStore } from '@/live';
|
||||
import { getMap } from '../map-view';
|
||||
|
||||
/**
|
||||
* Initial framing on event-load.
|
||||
*
|
||||
* Fits the map to the bounds of every device in the active event's
|
||||
* snapshot — runs at most once per `activeEventId` change. After the
|
||||
* initial fit the user owns the camera; later position updates don't
|
||||
* snap back.
|
||||
*
|
||||
* Side-effect-only: returns null.
|
||||
*/
|
||||
export function MapDefaultCamera() {
|
||||
const activeEventId = usePositionStore((s) => s.activeEventId);
|
||||
const latestByDevice = usePositionStore((s) => s.latestByDevice);
|
||||
const fittedFor = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeEventId) {
|
||||
fittedFor.current = null;
|
||||
return;
|
||||
}
|
||||
if (fittedFor.current === activeEventId) return;
|
||||
if (latestByDevice.size === 0) return; // wait for snapshot
|
||||
|
||||
const map = getMap();
|
||||
const positions = Array.from(latestByDevice.values());
|
||||
|
||||
if (positions.length === 1) {
|
||||
const p = positions[0]!;
|
||||
map.easeTo({ center: [p.lon, p.lat], zoom: 12, duration: 0 });
|
||||
} else {
|
||||
const bounds = new LngLatBounds();
|
||||
for (const p of positions) bounds.extend([p.lon, p.lat]);
|
||||
const canvas = map.getCanvas();
|
||||
const padding = Math.min(canvas.clientWidth, canvas.clientHeight) * 0.1;
|
||||
map.fitBounds(bounds, { padding: Math.min(padding, 80), duration: 0 });
|
||||
}
|
||||
|
||||
fittedFor.current = activeEventId;
|
||||
}, [activeEventId, latestByDevice]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { MapDefaultCamera } from './default-camera';
|
||||
export { MapSelectedDevice } from './selected-device';
|
||||
export { MapCamera } from './map-camera';
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useEffect } from 'react';
|
||||
import { LngLatBounds } from 'maplibre-gl';
|
||||
import { getMap } from '../map-view';
|
||||
|
||||
/**
|
||||
* Imperative one-shot camera fit. Pass `coordinates` (or a single point)
|
||||
* and the camera animates to fit them. Used by future features:
|
||||
*
|
||||
* - "Fit all" button — coordinates = every visible device.
|
||||
* - Replay scrubbing (Phase 4) — coordinates = the replay frame's window.
|
||||
*
|
||||
* Re-runs when `coordinates` changes by reference. Pass a stable array if
|
||||
* you don't want repeated animations.
|
||||
*/
|
||||
export function MapCamera({
|
||||
coordinates,
|
||||
padding = 60,
|
||||
zoom,
|
||||
duration = 600,
|
||||
}: {
|
||||
coordinates: [number, number][];
|
||||
padding?: number;
|
||||
/** Fixed zoom override for single-point centring. Ignored for multi-point fits. */
|
||||
zoom?: number;
|
||||
duration?: number;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
if (coordinates.length === 0) return;
|
||||
const map = getMap();
|
||||
if (coordinates.length === 1) {
|
||||
map.easeTo({
|
||||
center: coordinates[0]!,
|
||||
zoom: zoom ?? Math.max(map.getZoom(), 14),
|
||||
duration,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const bounds = new LngLatBounds();
|
||||
for (const c of coordinates) bounds.extend(c);
|
||||
map.fitBounds(bounds, { padding, duration });
|
||||
}, [coordinates, padding, zoom, duration]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { usePositionStore } from '@/live';
|
||||
import { useMapPrefStore } from '../map-pref-store';
|
||||
import { getMap } from '../map-view';
|
||||
|
||||
/**
|
||||
* Reactive follow for the selected device.
|
||||
*
|
||||
* Two distinct behaviours, dispatched by what changed:
|
||||
*
|
||||
* 1. **Selection change** — always pan + zoom in (clamped to zoom ≥ 14).
|
||||
* The user just clicked the device, they want to see it.
|
||||
* 2. **Position update with `mapFollow` on** — smooth pan only, no zoom
|
||||
* change. Updates run at the rAF-coalesced rate of the position store.
|
||||
*
|
||||
* Plus: manual map-pan auto-disables `mapFollow` so the user isn't
|
||||
* fighting the camera. We distinguish user gestures from programmatic
|
||||
* `easeTo`s via `MapMouseEvent.originalEvent` (truthy on user gestures,
|
||||
* absent on our own animations).
|
||||
*/
|
||||
export function MapSelectedDevice() {
|
||||
const selectedDeviceId = usePositionStore((s) => s.selectedDeviceId);
|
||||
const latest = usePositionStore((s) => s.latestByDevice);
|
||||
const mapFollow = useMapPrefStore((s) => s.mapFollow);
|
||||
const lastSelection = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedDeviceId) {
|
||||
lastSelection.current = null;
|
||||
return;
|
||||
}
|
||||
const position = latest.get(selectedDeviceId);
|
||||
if (!position) return;
|
||||
|
||||
const map = getMap();
|
||||
|
||||
if (lastSelection.current !== selectedDeviceId) {
|
||||
lastSelection.current = selectedDeviceId;
|
||||
map.easeTo({
|
||||
center: [position.lon, position.lat],
|
||||
zoom: Math.max(map.getZoom(), 14),
|
||||
duration: 600,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (mapFollow) {
|
||||
map.easeTo({
|
||||
center: [position.lon, position.lat],
|
||||
duration: 300,
|
||||
});
|
||||
}
|
||||
}, [selectedDeviceId, latest, mapFollow]);
|
||||
|
||||
// Manual-pan detection: any move with an `originalEvent` is a user
|
||||
// gesture and should disable follow. Programmatic `easeTo`s have no
|
||||
// originalEvent, so they don't trigger this path.
|
||||
useEffect(() => {
|
||||
const map = getMap();
|
||||
type MoveEv = { originalEvent?: unknown };
|
||||
const onMoveStart = (ev: MoveEv): void => {
|
||||
if (ev.originalEvent != null) {
|
||||
const { mapFollow: follow, setMapFollow } = useMapPrefStore.getState();
|
||||
if (follow) setMapFollow(false);
|
||||
}
|
||||
};
|
||||
map.on('movestart', onMoveStart);
|
||||
return () => {
|
||||
map.off('movestart', onMoveStart);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Locate, LocateOff } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useMapPrefStore } from './map-pref-store';
|
||||
|
||||
/**
|
||||
* Small icon-button toggling `mapFollow`. Sits in the chrome below the
|
||||
* trails toggle.
|
||||
*
|
||||
* Behaviour: when on, the camera pans to follow the selected device's
|
||||
* position updates. When off, the camera stays where the user put it.
|
||||
* Manually panning the map auto-disables follow (the
|
||||
* `<MapSelectedDevice>` component handles that).
|
||||
*/
|
||||
export function FollowToggle() {
|
||||
const mapFollow = useMapPrefStore((s) => s.mapFollow);
|
||||
const setMapFollow = useMapPrefStore((s) => s.setMapFollow);
|
||||
|
||||
return (
|
||||
<div className="absolute top-56 right-3 bg-background border border-border rounded-md shadow-sm overflow-hidden z-10">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMapFollow(!mapFollow)}
|
||||
title={mapFollow ? 'Following selected device' : 'Click to follow selected device'}
|
||||
aria-pressed={mapFollow}
|
||||
className={cn(
|
||||
'flex items-center gap-2 w-full px-3 py-1.5 text-sm transition-colors',
|
||||
mapFollow ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/50 text-foreground',
|
||||
)}
|
||||
>
|
||||
{mapFollow ? (
|
||||
<Locate className="size-4" aria-hidden />
|
||||
) : (
|
||||
<LocateOff className="size-4" aria-hidden />
|
||||
)}
|
||||
<span>Follow</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,11 +7,14 @@ export type TrailMode = 'none' | 'selected' | 'all';
|
||||
type MapPrefState = {
|
||||
basemapId: BasemapId;
|
||||
trailMode: TrailMode;
|
||||
/** When true, the camera pans to follow the selected device's position updates. */
|
||||
mapFollow: boolean;
|
||||
};
|
||||
|
||||
type MapPrefActions = {
|
||||
setBasemap: (id: BasemapId) => void;
|
||||
setTrailMode: (mode: TrailMode) => void;
|
||||
setMapFollow: (follow: boolean) => void;
|
||||
};
|
||||
|
||||
type Store = MapPrefState & MapPrefActions;
|
||||
@@ -26,8 +29,10 @@ export const useMapPrefStore = create<Store>()(
|
||||
(set) => ({
|
||||
basemapId: DEFAULT_BASEMAP_ID,
|
||||
trailMode: 'selected',
|
||||
mapFollow: true,
|
||||
setBasemap: (id) => set({ basemapId: id }),
|
||||
setTrailMode: (mode) => set({ trailMode: mode }),
|
||||
setMapFollow: (follow) => set({ mapFollow: follow }),
|
||||
}),
|
||||
{
|
||||
name: 'trm-map-prefs',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useId } from 'react';
|
||||
import type { Feature, FeatureCollection, Point } from 'geojson';
|
||||
import type { GeoJSONSource, MapLayerMouseEvent } from 'maplibre-gl';
|
||||
import { usePositionStore } from '@/live';
|
||||
import { usePositionStore, useStalenessTick } from '@/live';
|
||||
import { type PositionEntry } from '@/live/protocol';
|
||||
import { getMap } from '@/map/core/map-view';
|
||||
import { inferColor, mapCategoryToSprite } from '@/map/core/categories';
|
||||
@@ -14,6 +14,8 @@ type FeatureProps = {
|
||||
course: number;
|
||||
direction: boolean;
|
||||
title: string;
|
||||
/** Seconds since this device's last position. Drives the fade on icon-opacity. */
|
||||
staleSec: number;
|
||||
};
|
||||
|
||||
const EMPTY_FC: FeatureCollection<Point, FeatureProps> = {
|
||||
@@ -47,6 +49,9 @@ export function MapPositions() {
|
||||
const latestByDevice = usePositionStore((s) => s.latestByDevice);
|
||||
const selectedDeviceId = usePositionStore((s) => s.selectedDeviceId);
|
||||
const { data: devices } = useDevicesById();
|
||||
// 1 Hz tick keeps `staleSec` on every feature fresh so the icon-opacity
|
||||
// fade reflects "device went silent" without waiting for a new position.
|
||||
const stalenessTick = useStalenessTick(1000);
|
||||
|
||||
// ---- Setup / teardown ------------------------------------------------
|
||||
|
||||
@@ -87,6 +92,34 @@ export function MapPositions() {
|
||||
'text-color': '#0E0E0C',
|
||||
'text-halo-color': '#FAFAF7',
|
||||
'text-halo-width': 1.5,
|
||||
// Fade older markers based on the device's last-position age.
|
||||
// 0–60 s: full opacity. 5 min: 0.4. 30 min+: 0.2.
|
||||
'icon-opacity': [
|
||||
'interpolate',
|
||||
['linear'],
|
||||
['get', 'staleSec'],
|
||||
0,
|
||||
1.0,
|
||||
60,
|
||||
1.0,
|
||||
300,
|
||||
0.4,
|
||||
1800,
|
||||
0.2,
|
||||
],
|
||||
'text-opacity': [
|
||||
'interpolate',
|
||||
['linear'],
|
||||
['get', 'staleSec'],
|
||||
0,
|
||||
1.0,
|
||||
60,
|
||||
1.0,
|
||||
300,
|
||||
0.5,
|
||||
1800,
|
||||
0.25,
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -145,6 +178,19 @@ export function MapPositions() {
|
||||
'text-color': '#0E0E0C',
|
||||
'text-halo-color': '#FAFAF7',
|
||||
'text-halo-width': 2,
|
||||
'icon-opacity': [
|
||||
'interpolate',
|
||||
['linear'],
|
||||
['get', 'staleSec'],
|
||||
0,
|
||||
1.0,
|
||||
60,
|
||||
1.0,
|
||||
300,
|
||||
0.5,
|
||||
1800,
|
||||
0.3,
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -243,12 +289,20 @@ export function MapPositions() {
|
||||
latestByDevice,
|
||||
selectedDeviceId,
|
||||
devices,
|
||||
now: stalenessTick,
|
||||
});
|
||||
const ns = map.getSource(nonSelectedSourceId) as GeoJSONSource | undefined;
|
||||
const sel = map.getSource(selectedSourceId) as GeoJSONSource | undefined;
|
||||
ns?.setData(nonSelected);
|
||||
sel?.setData(selected);
|
||||
}, [latestByDevice, selectedDeviceId, devices, nonSelectedSourceId, selectedSourceId]);
|
||||
}, [
|
||||
latestByDevice,
|
||||
selectedDeviceId,
|
||||
devices,
|
||||
stalenessTick,
|
||||
nonSelectedSourceId,
|
||||
selectedSourceId,
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -259,6 +313,7 @@ function buildFeatureCollections(opts: {
|
||||
latestByDevice: Map<string, PositionEntry>;
|
||||
selectedDeviceId: string | null;
|
||||
devices: Map<string, Device>;
|
||||
now: number;
|
||||
}): {
|
||||
nonSelected: FeatureCollection<Point, FeatureProps>;
|
||||
selected: FeatureCollection<Point, FeatureProps>;
|
||||
@@ -269,7 +324,7 @@ function buildFeatureCollections(opts: {
|
||||
for (const [deviceId, position] of opts.latestByDevice) {
|
||||
const isSelected = deviceId === opts.selectedDeviceId;
|
||||
const device = opts.devices.get(deviceId);
|
||||
const feat = buildPositionFeature(position, device, isSelected);
|
||||
const feat = buildPositionFeature(position, device, isSelected, opts.now);
|
||||
if (isSelected) selectedFeatures.push(feat);
|
||||
else nonSelectedFeatures.push(feat);
|
||||
}
|
||||
@@ -284,6 +339,7 @@ function buildPositionFeature(
|
||||
p: PositionEntry,
|
||||
device: Device | undefined,
|
||||
isSelected: boolean,
|
||||
now: number,
|
||||
): Feature<Point, FeatureProps> {
|
||||
// Phase 1 schema doesn't carry `kind` on devices; map all to 'default'
|
||||
// for now. When the schema gains a kind/category column (Phase 2 of
|
||||
@@ -301,6 +357,7 @@ function buildPositionFeature(
|
||||
// Show the direction arrow only when the device is moving (>1 m/s).
|
||||
direction: p.course != null && (p.speed ?? 0) > 1,
|
||||
title: deviceLabel(device, p.deviceId),
|
||||
staleSec: Math.max(0, Math.floor((now - p.ts) / 1000)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ function HomePage() {
|
||||
</header>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Live monitoring map</CardTitle>
|
||||
<CardTitle>Live monitoring map (DEV-HOST)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
|
||||
@@ -1,15 +1,48 @@
|
||||
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 { MapDefaultCamera, MapSelectedDevice } from '@/map/core/camera';
|
||||
import { FollowToggle } from '@/map/core/follow-toggle';
|
||||
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 { ConnectionChip } from '@/ui/components/connection-chip';
|
||||
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,8 +53,15 @@ function MonitorPage() {
|
||||
UI controls. Floating cards positioned absolutely inside
|
||||
<MapView>'s wrapper.
|
||||
*/}
|
||||
<EventPicker
|
||||
activeEventId={activeEventId}
|
||||
onChange={(id) => {
|
||||
void setActiveEvent(id);
|
||||
}}
|
||||
/>
|
||||
<BasemapSwitcher />
|
||||
<TrailsToggle />
|
||||
<FollowToggle />
|
||||
{/*
|
||||
Layer order matters — MapLibre paints in addLayer() call order.
|
||||
<MapTrails /> mounts first so its line layer renders below the
|
||||
@@ -29,6 +69,18 @@ function MonitorPage() {
|
||||
*/}
|
||||
<MapTrails />
|
||||
<MapPositions />
|
||||
{/*
|
||||
Camera components are side-effect-only (return null). They sit
|
||||
at the end so they react to whatever <MapPositions /> just
|
||||
surfaced (selected device, latest positions).
|
||||
*/}
|
||||
<MapDefaultCamera />
|
||||
<MapSelectedDevice />
|
||||
{/*
|
||||
Status indicator. Bottom-left, subtle when connected, louder
|
||||
when reconnecting / offline.
|
||||
*/}
|
||||
<ConnectionChip />
|
||||
</MapView>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { formatLastSeen, useConnectionStore } from '@/live';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Subtle WS-status indicator. Sits bottom-left of the monitor route.
|
||||
*
|
||||
* Three visible states:
|
||||
* - **Connected** — small green dot + muted "Live" label.
|
||||
* - **Connecting / reconnecting** — amber pulsing dot + status word.
|
||||
* - **Disconnected** — red dot + "Offline · last live HH:MM" so the
|
||||
* operator can see how long the gap has been.
|
||||
*
|
||||
* Designed to be ignorable when everything's fine and unmissable when
|
||||
* it isn't — same posture as the design system's "live" pulse.
|
||||
*/
|
||||
export function ConnectionChip() {
|
||||
const status = useConnectionStore((s) => s.status);
|
||||
const lastConnectedAt = useConnectionStore((s) => s.lastConnectedAt);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute bottom-3 left-3 z-10 flex items-center gap-2',
|
||||
'bg-background/90 backdrop-blur-sm border border-border rounded-md px-2.5 py-1.5 shadow-sm',
|
||||
'text-xs',
|
||||
)}
|
||||
aria-live="polite"
|
||||
role="status"
|
||||
>
|
||||
{status === 'connected' && (
|
||||
<>
|
||||
<span className="size-2 rounded-full bg-green-600" aria-hidden />
|
||||
<span className="text-muted-foreground">Live</span>
|
||||
</>
|
||||
)}
|
||||
{(status === 'connecting' || status === 'reconnecting') && (
|
||||
<>
|
||||
<span className="size-2 rounded-full bg-amber-500 animate-pulse" aria-hidden />
|
||||
<span className="text-muted-foreground">
|
||||
{status === 'connecting' ? 'Connecting…' : 'Reconnecting…'}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{status === 'disconnected' && (
|
||||
<>
|
||||
<span className="size-2 rounded-full bg-destructive" aria-hidden />
|
||||
<span className="text-destructive">
|
||||
Offline
|
||||
{lastConnectedAt ? <> · last live {formatLastSeen(lastConnectedAt)}</> : null}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -2,7 +2,7 @@ import path from 'node:path';
|
||||
import { defineConfig, loadEnv } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { TanStackRouterVite } from '@tanstack/router-plugin/vite';
|
||||
import { tanstackRouter } from '@tanstack/router-plugin/vite';
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig(({ mode }) => {
|
||||
@@ -16,7 +16,7 @@ export default defineConfig(({ mode }) => {
|
||||
plugins: [
|
||||
// Router plugin must run BEFORE @vitejs/plugin-react so the generated
|
||||
// routeTree is in place when React's transform runs.
|
||||
TanStackRouterVite({ target: 'react', autoCodeSplitting: true }),
|
||||
tanstackRouter({ target: 'react', autoCodeSplitting: true }),
|
||||
react(),
|
||||
tailwindcss(),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user