Files
spa/.planning/phase-2-live-map/09-connection-status.md
T
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

176 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Task 2.9 — Connection status + per-device last-seen indicators
**Phase:** 2 — Live monitoring map
**Status:** ⬜ Not started
**Depends on:** 2.4.
**Wiki refs:** `docs/wiki/concepts/maps-architecture.md` §"Visible system state".
## Goal
Show operators _just enough_ about system state so they can answer two questions at a glance:
1. "Is the SPA still connected to live data?" — global WS status.
2. "Is this specific device still reporting?" — per-device last-seen age.
Not noisy. Subtle UI; not banners and modal warnings. The design ethos: operators trust the map until something goes wrong, at which point they need a quick read of _what_ went wrong without a wall of red.
## Deliverables
- **`src/ui/components/connection-chip.tsx`** — `<ConnectionChip />` rendered in the monitor route's chrome (top-right corner of the map UI, near the basemap switcher). Reads `useConnectionStore`. Three visible states:
- **Connected** — small green dot, hidden text (or "Live" only on hover/expand). Subtle.
- **Reconnecting** — amber dot pulsing, text "Reconnecting…" with attempt count if useful.
- **Disconnected / offline** — red dot, text "Offline — last live: 14:02:11" showing the last successful contact time.
- **`src/ui/components/device-last-seen.tsx`** — `<DeviceLastSeen deviceId>` component. Renders nothing in normal state; renders a small subscript / icon when the device's last position is older than threshold (default: 60s).
- Used inside `<MapPositions>`'s feature properties (or as a separate symbol layer keyed off the `lastSeenAge` derived field), and in any future per-device sidebar.
- **`src/live/connection-store.ts`** updated — adds `lastConnectedAt: number | null` (already in 2.4's spec) and `lastDisconnectedAt: number | null`. Toast / chip use these for "last live: N min ago" formatting.
- **`src/live/last-seen.ts`** — utility: `formatLastSeen(ts: number, now = Date.now()): string` returns `"now"` / `"5s ago"` / `"2m ago"` / `"14:02"` based on age. Used by both the chip and the per-device indicator.
- **A "stale-position" derived signal** — at coalescer flush time, the position store can compute `staleByDevice: Set<string>` for devices whose last update is older than `STALE_THRESHOLD_MS` (default 60s). Map layers reading this Set apply a faded-icon variant. Or: a separate symbol layer renders a "warning" badge over stale devices' markers.
## Specification
### `<ConnectionChip />`
```tsx
export function ConnectionChip() {
const status = useConnectionStore((s) => s.status);
const lastConnectedAt = useConnectionStore((s) => s.lastConnectedAt);
if (status === 'connected') {
return (
<div className="flex items-center gap-1.5 text-xs">
<span className="w-2 h-2 rounded-full bg-green-500" aria-hidden />
<span className="text-muted-foreground">Live</span>
</div>
);
}
if (status === 'connecting' || status === 'reconnecting') {
return (
<div className="flex items-center gap-1.5 text-xs">
<span className="w-2 h-2 rounded-full bg-amber-500 animate-pulse" aria-hidden />
<span className="text-muted-foreground">
{status === 'connecting' ? 'Connecting…' : 'Reconnecting…'}
</span>
</div>
);
}
return (
<div className="flex items-center gap-1.5 text-xs">
<span className="w-2 h-2 rounded-full bg-destructive" aria-hidden />
<span className="text-destructive">
Offline {lastConnectedAt ? `· last live ${formatLastSeen(lastConnectedAt)}` : ''}
</span>
</div>
);
}
```
Position: top-right of the monitor route, in a small horizontal bar with the basemap switcher and other controls. Inline-flex.
### Per-device last-seen — two strategies
**Strategy A: Faded marker.** A separate symbol layer with a `'icon-opacity'` expression based on a `staleSec` property on each feature:
```ts
'icon-opacity': [
'interpolate', ['linear'], ['get', 'staleSec'],
0, 1.0, // fresh
60, 1.0, // 60s — full opacity
300, 0.4, // 5min — faded
1800, 0.2, // 30min — very faded
],
```
The position store includes `staleSec` in each feature's properties at coalescer-flush time.
**Strategy B: Badge / overlay.** A second symbol layer drawing a small "no-signal" icon over stale markers, filtered to `['>=', ['get', 'staleSec'], 60]`.
**Pick A for v1.** Simpler, fewer layers, cleaner visually. B can layer on top later if A isn't legible enough.
### Updating `staleSec`
Recomputed every second by a setInterval inside the position store (not on every position update — that would invalidate `latestByDevice` constantly):
```ts
// In src/live/position-store.ts
let staleTickerId: ReturnType<typeof setInterval> | null = null;
function startStaleTicker() {
if (staleTickerId) return;
staleTickerId = setInterval(() => {
const now = Date.now();
set((state) => {
// Only bump versionTick if any device's stale-bucket changed.
// ... or just trigger subscribers wholesale every second.
return { stalenessTick: now };
});
}, 1000);
}
```
Map layers re-derive `staleSec` from `latestByDevice` + `stalenessTick` on every flush. The store doesn't mutate position data; it just bumps a version tick that triggers selectors that compute `staleSec` themselves.
### Logout / unmount cleanup
The stale ticker runs as long as the SPA is mounted. Stop on logout (status flips to anonymous) or when navigating away from `/monitor`. Use a `useEffect` in `<MonitorPage>`:
```tsx
useEffect(() => {
startStaleTicker();
return () => stopStaleTicker();
}, []);
```
### What this task does NOT include
- **Loud "device offline!" notifications.** No toast, no modal. The faded icon is the signal. If operators need louder, Phase 3.4's per-device detail panel can add an alert badge.
- **Per-event "X devices offline" summary.** Phase 3 polish.
- **Auto-recovery testing for the WS reconnect.** That's covered by 2.4's acceptance.
- **Notification API integration.** Browser push on disconnect — Phase 4.
## Acceptance criteria
- [ ] `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, `pnpm build` clean.
- [ ] On `/monitor`, the connection chip top-right shows "Live" with a green dot during normal operation.
- [ ] Disconnecting the network: the chip flips to "Reconnecting…" within a few seconds; on reconnect, back to "Live"; on permanent disconnect (close + no reconnect for 30s+), shows "Offline · last live HH:MM:SS".
- [ ] A device that hasn't reported in 5+ minutes appears noticeably faded on the map (not invisible — operators still see _where it was last seen_).
- [ ] On a fresh page load with no positions yet, the chip says "Connecting…" briefly, then "Live" once the snapshot arrives.
- [ ] No banner / toast spam during normal operation.
## Risks / open questions
- **Threshold tuning.** 60s = "fresh", 5min = "fading", 30min = "stale". For Teltonika devices reporting at 1Hz this is generous; for 0.2Hz devices the 60s window catches a device that just paused at a checkpoint. Watch dogfood data and adjust.
- **Setinterval ticker accuracy.** `setInterval(1000ms)` is approximate; on a slow tab it can drift. The visual effect doesn't need wall-clock precision — "this marker is faded because it's been a while" is still correct even if the timer drifted by a few seconds.
- **Race with reconnect on offline.** If the SPA is offline and reconnects, the snapshot replays; some markers' last-seen will jump from "stale" to "fresh" in one frame. Verify the visual transition is clean (no flicker).
- **Connection chip during boot.** Right after login, the WS hasn't connected yet — chip says "Connecting…". Should this be its own state, or just folded into "reconnecting"? Spec above keeps `connecting` distinct for clarity; either is defensible.
## Done
- **`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 `PENDING_SHA`.