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.
This commit is contained in:
@@ -192,7 +192,7 @@ Click toggles `mapFollow`. Selecting a device with follow off still pans to it o
|
||||
## Done
|
||||
|
||||
- **`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/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.
|
||||
@@ -207,6 +207,7 @@ Click toggles `mapFollow`. Selecting a device with follow off still pans to it o
|
||||
- `<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.
|
||||
|
||||
@@ -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 `PENDING_SHA`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -56,7 +56,7 @@ When Phase 2 is done:
|
||||
| 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.9 | [Connection status + per-device last-seen indicators](./09-connection-status.md) | 🟩 |
|
||||
|
||||
## Files modified
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@ 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;
|
||||
}
|
||||
@@ -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)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ 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')({
|
||||
@@ -75,6 +76,11 @@ function MonitorPage() {
|
||||
*/}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user