` feature so the icon-opacity fade reflects "device went silent" without waiting for a new position.
+- **`src/ui/components/connection-chip.tsx`** — `` 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 `` inside ``.
+- **`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 `` 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`.
diff --git a/.planning/phase-2-live-map/README.md b/.planning/phase-2-live-map/README.md
index 292d918..1976f8b 100644
--- a/.planning/phase-2-live-map/README.md
+++ b/.planning/phase-2-live-map/README.md
@@ -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
diff --git a/src/live/index.ts b/src/live/index.ts
index 42c338e..519a669 100644
--- a/src/live/index.ts
+++ b/src/live/index.ts
@@ -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,
diff --git a/src/live/last-seen.ts b/src/live/last-seen.ts
new file mode 100644
index 0000000..2b70bb9
--- /dev/null
+++ b/src/live/last-seen.ts
@@ -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' });
+}
diff --git a/src/live/use-staleness.ts b/src/live/use-staleness.ts
new file mode 100644
index 0000000..201331c
--- /dev/null
+++ b/src/live/use-staleness.ts
@@ -0,0 +1,26 @@
+import { useEffect, useState } from 'react';
+
+/**
+ * Re-renders subscribers every `intervalMs` with the current epoch ms.
+ *
+ * Used by `` 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(() => Date.now());
+ useEffect(() => {
+ const id = setInterval(() => {
+ setTick(Date.now());
+ }, intervalMs);
+ return () => {
+ clearInterval(id);
+ };
+ }, [intervalMs]);
+ return tick;
+}
diff --git a/src/map/layers/map-positions.tsx b/src/map/layers/map-positions.tsx
index 6103a1b..bd389e5 100644
--- a/src/map/layers/map-positions.tsx
+++ b/src/map/layers/map-positions.tsx
@@ -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 = {
@@ -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;
selectedDeviceId: string | null;
devices: Map;
+ now: number;
}): {
nonSelected: FeatureCollection;
selected: FeatureCollection;
@@ -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 {
// 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)),
},
};
}
diff --git a/src/routes/_authed/monitor.tsx b/src/routes/_authed/monitor.tsx
index 87775b9..f782d3a 100644
--- a/src/routes/_authed/monitor.tsx
+++ b/src/routes/_authed/monitor.tsx
@@ -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() {
*/}
+ {/*
+ Status indicator. Bottom-left, subtle when connected, louder
+ when reconnecting / offline.
+ */}
+
);
diff --git a/src/ui/components/connection-chip.tsx b/src/ui/components/connection-chip.tsx
new file mode 100644
index 0000000..925a0cb
--- /dev/null
+++ b/src/ui/components/connection-chip.tsx
@@ -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 (
+
+ {status === 'connected' && (
+ <>
+
+ Live
+ >
+ )}
+ {(status === 'connecting' || status === 'reconnecting') && (
+ <>
+
+
+ {status === 'connecting' ? 'Connecting…' : 'Reconnecting…'}
+
+ >
+ )}
+ {status === 'disconnected' && (
+ <>
+
+
+ Offline
+ {lastConnectedAt ? <> · last live {formatLastSeen(lastConnectedAt)}> : null}
+
+ >
+ )}
+
+ );
+}