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:
2026-05-03 00:17:09 +02:00
parent 18d893f47a
commit ed88f1767d
9 changed files with 198 additions and 7 deletions
+2
View File
@@ -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,
+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;
}
+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)),
},
};
}
+6
View File
@@ -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>
);
+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>
);
}