Compare commits

..

10 Commits

Author SHA1 Message Date
julian 64b3291dd3 chore: add additional entries to .prettierignore for planning, gitea, and tanstack 2026-05-03 09:37:13 +02:00
julian bc626a5960 fix: Deprecated TanStackRouterVite, correct import and usage of tanstackRouter in vite.config.ts 2026-05-03 09:34:10 +02:00
julian 89086ef53e docs: backfill task 2.9 commit SHA 2026-05-03 09:34:06 +02:00
julian ed88f1767d feat: task 2.9 connection status + per-device staleness fade — Phase 2 done
- src/live/last-seen.ts: formatLastSeen(ts, now) — "now" / "Ns ago" /
  "Nm ago" / "HH:MM".
- src/live/use-staleness.ts: useStalenessTick(intervalMs=1000) hook
  re-renders subscribers every interval with the current epoch ms.
- src/ui/components/connection-chip.tsx: <ConnectionChip /> bottom-left,
  three states (connected / connecting+reconnecting / disconnected),
  aria-live polite + role status.
- src/map/layers/map-positions.tsx: every FeatureProps now carries
  staleSec; both symbol layers interpolate icon-opacity (and text-opacity
  on the non-selected layer) on staleSec — 0-60s full opacity,
  5min faded, 30min very faded. Update effect rebuilds features at the
  staleness tick rate (1Hz).
- src/routes/_authed/monitor.tsx: renders <ConnectionChip />.
- src/live/index.ts: re-exports formatLastSeen + useStalenessTick.

Strategy A (faded marker via interpolation) chosen over Strategy B
(separate warning-badge layer) per the task's open-question
resolution; simpler and easier to extend later.

Deviations:
1. stalenessTick is its own hook in src/live/use-staleness.ts rather
   than a property on the position store — keeps the store clean of
   UI-driven re-render concerns; the hook is reusable.
2. <DeviceLastSeen deviceId> standalone component skipped — the SPA
   doesn't have a sidebar yet (Phase 3.4); the per-marker fade IS the
   indicator for v1.

Bundle: main 396KB / 121KB gz — small bump from 2.8.

🎉 Phase 2 — Live monitoring map — complete. All 9 tasks shipped.
End-to-end: login -> /monitor -> event auto-selects -> snapshot
positions render -> live updates flow via WS through the rAF
coalescer -> staleness fades stale markers -> connection chip
surfaces WS state. Dogfood-blocking work for Rally Albania 2026 done.
2026-05-03 09:33:41 +02:00
julian 18d893f47a docs: backfill task 2.8 commit SHA 2026-05-03 09:33:26 +02:00
julian 17439de34f feat: task 2.8 camera control trio + follow toggle
- 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.
2026-05-03 09:33:08 +02:00
julian cc4cd8ddbf docs: backfill task 2.7 commit SHA 2026-05-03 09:32:55 +02:00
julian 564b7881bd 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).
2026-05-03 09:32:37 +02:00
julian 5cb859b403 docs: mark task 2.6 done in ROADMAP 2026-05-03 09:32:24 +02:00
julian e2ea4e6c08 docs: backfill task 2.6 done + ROADMAP 2026-05-03 09:32:11 +02:00
22 changed files with 770 additions and 16 deletions
+3 -2
View File
@@ -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`.
+35 -1
View File
@@ -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`.
+27 -1
View File
@@ -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` (060 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`.
+5 -5
View File
@@ -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
View File
@@ -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
+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.
}
}, []);
}
+3
View File
@@ -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,
+19
View File
@@ -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' });
}
+26
View File
@@ -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;
}
+47
View File
@@ -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;
}
+3
View File
@@ -0,0 +1,3 @@
export { MapDefaultCamera } from './default-camera';
export { MapSelectedDevice } from './selected-device';
export { MapCamera } from './map-camera';
+44
View File
@@ -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;
}
+74
View File
@@ -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;
}
+39
View File
@@ -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>
);
}
+5
View File
@@ -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',
+60 -3
View File
@@ -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.
// 060 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)),
},
};
}
+52
View File
@@ -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>
);
+55
View File
@@ -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>
);
}
+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);
}
}
+2 -2
View File
@@ -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(),
],