// 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 ( {fleetReportsEnabled ? ( ) : ( )} ); } function OdometerReportContent({ roverIds, ...props }) { // A lazy state initializer establishes one stable 24-hour query boundary; // ordinary odometer re-renders must not trigger fresh historical requests. const [rangeEnd] = useState(() => Date.now()); const { report } = useFleetReport({ since: rangeEnd - 24 * 60 * 60 * 1000, until: rangeEnd, compact: true, includeEvents: false, roverIds, }); const reportByRoverId = useMemo( () => Object.fromEntries((report?.rovers || []).map((rover) => [String(rover.roverId), rover])), [report], ); return ; } function OdometerContent({ now, primary, primaryRover, primaryRoverId, rows, reportByRoverId = {} }) { const primaryReport = primaryRoverId ? reportByRoverId[String(primaryRoverId)] : null; return ( <> {primaryRoverId ? ( ) : (

You do not currently have a rover.

)} ); } function OdometerSummary({ odometer, rover, report, now }) { return (
{roverLabel(rover)} {formatAge(odometer?.updatedAt, now)}
); } function Metric({ label, value }) { return (
{label} {value ?? '--'}
); } function RoverOdometerList({ rows, primaryRoverId, reportByRoverId }) { if (!rows.length) return null; return ( {rows.map(({ rover, odometer }) => { const active = String(rover?.id) === String(primaryRoverId); const report = reportByRoverId[String(rover?.id)]; return (
{roverLabel(rover)} {formatDistance(odometer?.totalMm)} {formatHealth(report?.batteryHealth?.capacityRetentionPercent)} {formatEfficiency(report?.overallWhPerKm)}
); })}
); } function DetailCard({ title, children }) { return (
{title}
{children}
); }