// Odometer Panel
// Purpose: Renders persistent rover distance totals derived from Roomba wheel encoders.
// Scope: Keeps odometer display and socket subscription logic isolated from the raw sensor panel.
import { useEffect, useMemo, useState } from 'react';
import { useSessionSelector } from '../../context/SessionContext.jsx';
import { useSocket } from '../../context/SocketContext.jsx';
import useFleetReport from '../../hooks/useFleetReport.js';
import { isFeatureEnabled } from '../../lib/features.js';
import CardFrame from '../CardFrame/index.jsx';
function mapByRoverId(entries = []) {
const next = {};
entries.forEach((entry) => {
if (!entry?.roverId) return;
next[String(entry.roverId)] = entry;
});
return next;
}
function mergeOdometerMap(previous, entries = []) {
const next = { ...previous };
entries.forEach((entry) => {
if (!entry?.roverId) return;
next[String(entry.roverId)] = entry;
});
return next;
}
function formatDistance(mm) {
const value = Number(mm);
if (!Number.isFinite(value)) return '--';
if (value >= 1000 * 1000) return `${(value / (1000 * 1000)).toFixed(2)} km`;
if (value >= 1000) return `${(value / 1000).toFixed(1)} m`;
return `${Math.round(value)} mm`;
}
function formatAge(timestamp, now) {
const value = Number(timestamp);
if (!Number.isFinite(value)) return '--';
const ageMs = Math.max(0, now - value);
if (ageMs < 1500) return 'now';
if (ageMs < 60 * 1000) return `${Math.round(ageMs / 1000)}s ago`;
return `${Math.round(ageMs / (60 * 1000))}m ago`;
}
function formatSpeed(mmPerSecond) {
const value = Number(mmPerSecond);
if (!Number.isFinite(value)) return '--';
return `${(value / 1000).toFixed(2)} m/s`;
}
function formatHealth(percent) {
const value = Number(percent);
return Number.isFinite(value) ? `${value.toFixed(0)}%` : '--';
}
function formatEfficiency(whPerKm) {
const value = Number(whPerKm);
return Number.isFinite(value) ? `${value.toFixed(1)} Wh/km` : '--';
}
function roverLabel(rover) {
return rover?.name || rover?.id || 'Unknown rover';
}
export default function OdometerPanel() {
const socket = useSocket();
const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
const roster = useSessionSelector((state) => state.session?.roster ?? []);
const sessionOdometers = useSessionSelector((state) => state.session?.odometers ?? []);
const fleetReportsEnabled = useSessionSelector((state) => isFeatureEnabled(state, 'fleetReports'));
const sessionOdometerMap = useMemo(() => mapByRoverId(sessionOdometers), [sessionOdometers]);
const [liveOdometerMap, setLiveOdometerMap] = useState({});
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
function handleUpdate({ roverId, odometer } = {}) {
if (!roverId || !odometer) return;
setLiveOdometerMap((previous) => ({
...previous,
[String(roverId)]: odometer,
}));
}
socket.on('odometer:update', handleUpdate);
socket.emit('odometer:subscribe', {}, (response = {}) => {
if (Array.isArray(response.odometers)) {
setLiveOdometerMap((previous) => mergeOdometerMap(previous, response.odometers));
}
});
return () => {
socket.off('odometer:update', handleUpdate);
};
}, [socket]);
useEffect(() => {
const timer = window.setInterval(() => {
setNow(Date.now());
}, 1000);
return () => window.clearInterval(timer);
}, []);
const odometerMap = useMemo(
() => ({
...sessionOdometerMap,
...liveOdometerMap,
}),
[liveOdometerMap, sessionOdometerMap],
);
const visibleRows = useMemo(
() =>
roster.map((rover) => ({
rover,
odometer: odometerMap[String(rover.id)] || null,
})),
[odometerMap, roster],
);
const visibleRoverIds = useMemo(() => roster.map((rover) => String(rover.id)), [roster]);
/*
The emphasized summary represents "your rover", so it must follow the
actual assignment rather than silently promoting the first visible rover.
Unassigned users still retain the compact all-rover list below.
*/
const primaryRoverId = assignedRoverId || null;
const primaryRover = visibleRows.find((entry) => String(entry.rover?.id) === String(primaryRoverId))?.rover || null;
const primary = primaryRoverId ? odometerMap[String(primaryRoverId)] || null : null;
return (
You do not currently have a rover.
)}