ed88f1767d
- 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.
56 lines
1.9 KiB
TypeScript
56 lines
1.9 KiB
TypeScript
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>
|
|
);
|
|
}
|