// Rover Queues Panel // Purpose: Defines the Rover Queues Panel module and the local helpers/components used in this file. // Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit. import { useMemo, useState } from 'react'; import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx'; import { useSharedClock } from '../../hooks/useSharedClock.js'; import CardFrame from '../CardFrame/index.jsx'; import QueueTargetRow from '../QueueTargetRow/index.jsx'; import { trackAnalyticsEvent } from '../../analytics/index.js'; import { openExternalRover } from '../../lib/interInstanceTransfer.js'; import { ExternalInstancesCompact, InterInstancePopup } from '../InterInstancePanel/index.jsx'; import { isFeatureEnabled } from '../../lib/features.js'; import { useSettingsNamespace } from '../../settings/index.js'; function formatBattery(rover) { const percent = rover?.batteryState?.percentDisplay; if (percent == null) return '--'; return `${percent}%`; } function batteryClass(rover) { if (!rover?.batteryState) return 'text-slate-400'; if (rover.batteryState.urgentActive) return 'text-red-400'; if (rover.batteryState.warnActive) return 'text-amber-300'; return 'text-emerald-300'; } export default function RoverQueuesPanel({ title = 'Rovers', roster: rosterOverride = null, turnQueues: turnQueuesOverride = null, users: usersOverride = null, externalInstance = null, disabledOverlay = '', fillHeight = false, }) { const role = useSessionSelector((state) => state.session?.role || null); const localRoster = useSessionSelector((state) => state.session?.roster ?? []); const localTurnQueues = useSessionSelector((state) => state.session?.turnQueues ?? {}); const localUsers = useSessionSelector((state) => state.session?.users ?? []); const interInstanceEnabled = useSessionSelector((state) => isFeatureEnabled(state, 'interInstance')); const hasRemoteInstances = useSessionSelector( (state) => (state.session?.interInstances?.instances?.length ?? 0) > 0, ); const { value: pageSettings } = useSettingsNamespace('page', { interInstanceTransferSettings: true }); const selfId = useSessionSelector((state) => state.session?.socketId || null); const assignedRoverId = useSessionSelector((state) => String(state.session?.assignment?.roverId || '').trim()); const assignedRoverName = useSessionSelector((state) => { const roverId = String(state.session?.assignment?.roverId || '').trim(); if (!roverId) return ''; const rover = (state.session?.roster || []).find((entry) => String(entry?.id) === roverId); return rover?.name || roverId; }); const { requestControl, rebootOwnRover } = useSessionActions(); const [pending, setPending] = useState({}); const [rebootPending, setRebootPending] = useState(false); const [interInstancePopupOpen, setInterInstancePopupOpen] = useState(false); const externalMode = Boolean(externalInstance); const externalBlocked = Boolean(externalMode && disabledOverlay); const includeInterInstanceSettings = pageSettings?.interInstanceTransferSettings !== false; const roster = Array.isArray(rosterOverride) ? rosterOverride : localRoster; const turnQueues = turnQueuesOverride && typeof turnQueuesOverride === 'object' ? turnQueuesOverride : localTurnQueues; const users = Array.isArray(usersOverride) ? usersOverride : localUsers; const canRequest = useMemo( () => (externalMode ? !externalBlocked : role && role !== 'spectator'), [externalBlocked, externalMode, role], ); const adminCapable = useMemo( () => role === 'admin' || role === 'lockdown', [role], ); const hasDeadlines = useMemo( () => Object.values(turnQueues || {}).some((info) => info?.deadline || info?.idleDeadline), [turnQueues], ); /* Queue timers are shown in whole seconds, and several queue panels can be mounted across desktop/mobile/spectator layouts. Sharing the one-second clock keeps those labels in sync while using a single interval globally. */ const now = useSharedClock(1000, hasDeadlines); const rosterItems = useMemo(() => { const known = new Set(roster.map((rover) => String(rover.id))); const extra = Object.keys(turnQueues || {}) .filter((id) => !known.has(String(id))) .map((id) => ({ id, name: id, locked: false, batteryState: null })); return [...roster, ...extra]; }, [roster, turnQueues]); async function handleRequest(targetRoverId) { if (!targetRoverId) return; if (externalBlocked) return; if (externalMode) { /* External queue cards deliberately reuse the local row layout, but their action cannot go through this Socket.IO server. The row opens the remote instance and follows the saved Page setting for cookie/settings transfer instead of interrupting each click with a confirmation popup. */ openExternalRover(externalInstance, targetRoverId, { includeSettings: includeInterInstanceSettings }); return; } setPending((prev) => ({ ...prev, [targetRoverId]: true })); trackAnalyticsEvent('rover_queue_join', { roverId: targetRoverId, alreadyAssigned: assignedRoverId === String(targetRoverId), }); try { await requestControl(targetRoverId); trackAnalyticsEvent('rover_queue_join_result', { roverId: targetRoverId, status: 'accepted', }); } catch (err) { trackAnalyticsEvent('rover_queue_join_result', { roverId: targetRoverId, status: 'failed', reason: err?.message || 'unknown', }); alert(err.message); } finally { setPending((prev) => ({ ...prev, [targetRoverId]: false })); } } const lookupUser = (socketId) => users.find((u) => u.socketId === socketId) || { socketId, nickname: null, role: null }; async function handleRebootOwnRover() { if (rebootPending) return; const ok = window.confirm(`Reboot your rover "${assignedRoverName}" now?`); if (!ok) return; setRebootPending(true); trackAnalyticsEvent('rover_reboot_click', { roverId: assignedRoverId, scope: 'own_rover' }); try { await rebootOwnRover(); trackAnalyticsEvent('rover_reboot_result', { roverId: assignedRoverId, scope: 'own_rover', status: 'accepted' }); alert('Reboot command sent.'); } catch (err) { trackAnalyticsEvent('rover_reboot_result', { roverId: assignedRoverId, scope: 'own_rover', status: 'failed', reason: err?.message || 'unknown', }); alert(err.message); } finally { setRebootPending(false); } } const rebootAction = role !== 'spectator' && assignedRoverId ? ( ) : null; const headerActions = !externalMode ? ( <> {interInstanceEnabled && hasRemoteInstances ? ( ) : null} {rebootAction} ) : null; return ( <>
{rosterItems.length === 0 ? (

No rovers registered.

) : ( )} {externalBlocked ? (
{disabledOverlay}
) : null} {!externalMode && interInstanceEnabled ? : null}
{interInstancePopupOpen ? ( /* The popup remains owned by the local Rover Queues panel because its title-bar action opens it. External queue panels never render that action, which prevents recursively opening browsers from remote rows. */ setInterInstancePopupOpen(false)} /> ) : null} ); }