more queue related convergence for the future.

This commit is contained in:
legop3
2026-09-27 17:19:56 -04:00
parent 7231809140
commit 34936ab05c
54 changed files with 219 additions and 368 deletions
+1 -1
View File
@@ -63,7 +63,7 @@ function useChatComposerSessionState(allowSpectatorInput) {
const ptzTtsSupported = Boolean(
operatingMode === 'ptz' &&
ptz?.audio?.enabled &&
(ptz?.turn?.isActive || ptz?.turn?.turnsAhead > 0),
(ptz?.turn?.ownsControl || ptz?.turn?.turnsAhead > 0),
);
return {
@@ -55,7 +55,7 @@ function HudChatInput({ compact = false, variant = 'legacy', open = true, onOpen
const ptzTtsSupported = Boolean(
operatingMode === 'ptz' &&
ptz?.audio?.enabled &&
(ptz?.turn?.isActive || ptz?.turn?.turnsAhead > 0),
(ptz?.turn?.ownsControl || ptz?.turn?.turnsAhead > 0),
);
const ttsSupported = Boolean(rover?.audio?.ttsEnabled || ptzTtsSupported);
const ttsPayload = useMemo(() => {
@@ -40,10 +40,8 @@ function HudOverlay({
});
const derivedDriverLabel = useSessionSelector((state) => {
if (!effectiveRoverId || variant !== 'spectator') return null;
const activeId = state.session?.activeDrivers?.[effectiveRoverId] || null;
const users = state.session?.users || [];
const match = users.find((u) => String(u.socketId || '') === String(activeId || ''));
return match?.nickname || match?.name || null;
const turn = state.session?.roster?.find((rover) => String(rover.id) === String(effectiveRoverId))?.turn;
return turn?.queue?.find((entry) => entry.socketId === turn.currentId)?.name || null;
});
const resolvedSensors = sensors ?? null;
const resolvedLabel = label ?? rosterInfo.label ?? null;
@@ -7,16 +7,13 @@ import SocialButton from '../../SocialButton/index.jsx';
function TurnsOverlay({
roverId = null,
mobileHud = false,
turnModel = null,
}) {
const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
const effectiveRoverId = turnModel?.targetId ?? roverId ?? assignedRoverId;
const effectiveRoverId = roverId ?? assignedRoverId;
const mode = useSessionSelector((state) => state.session?.mode || null);
const roster = useSessionSelector((state) => state.session?.roster ?? []);
const users = useSessionSelector((state) => state.session?.users ?? []);
const turnQueues = useSessionSelector((state) => state.session?.turnQueues ?? {});
const socketId = useSessionSelector((state) => state.session?.socketId || null);
const activeDrivers = useSessionSelector((state) => state.session?.activeDrivers ?? {});
const lastControlIntentAt = useControlSelector((control) => control.state.lastControlIntentAt);
const [showTurnCue, setShowTurnCue] = useState(false);
const [turnCueStartAt, setTurnCueStartAt] = useState(null);
@@ -30,30 +27,15 @@ function TurnsOverlay({
const subClass = mobileHud ? 'text-xs' : 'text-sm';
const cueTimerClass = mobileHud ? 'text-[0.55rem]' : 'text-[0.75rem]';
const cuePadClass = mobileHud ? 'px-4 py-3' : 'px-6 py-4';
const turnInfo = turnModel ? null : effectiveRoverId ? turnQueues?.[effectiveRoverId] : null;
const activeDriverId = turnModel
? turnModel.activeId || null
: effectiveRoverId
? activeDrivers?.[effectiveRoverId] || null
: null;
const isActiveDriver = turnModel
? Boolean(turnModel.isActive)
: Boolean(socketId && activeDriverId === socketId);
const isTurnsMode = turnModel ? Boolean(turnModel.enabled) : mode === 'turns';
const turnInfo = roster.find((rover) => String(rover.id) === String(effectiveRoverId))?.turn;
const activeDriverId = turnInfo?.currentId || null;
const isActiveDriver = Boolean(turnInfo?.ownsControl);
const isTurnsMode = mode === 'turns';
const now = useSharedClock(1000, isTurnsMode);
const nextDriverId = useMemo(() => {
if (turnModel) return turnModel.nextId || null;
const queue = turnInfo?.queue || [];
if (!queue.length || !turnInfo?.current || queue.length <= 1) return null;
const idx = queue.findIndex((id) => id === turnInfo.current);
if (idx === -1) return queue[0] || null;
return queue[(idx + 1) % queue.length] || null;
}, [turnInfo, turnModel]);
const isNextDriver = turnModel
? Boolean(turnModel.isNext)
: Boolean(socketId && nextDriverId === socketId);
const deadline = turnModel ? turnModel.deadline || null : turnInfo?.deadline || null;
const idleDeadline = turnModel ? turnModel.idleDeadline || null : turnInfo?.idleDeadline || null;
const nextDriverId = turnInfo?.currentId ? turnInfo.nextId : null;
const isNextDriver = Boolean(socketId && nextDriverId === socketId);
const deadline = turnInfo?.deadline || null;
const idleDeadline = turnInfo?.idleDeadline || null;
const msUntilTurn = deadline ? deadline - now : null;
const msUntilIdleSkip = idleDeadline ? idleDeadline - now : null;
const totalRovers = roster.length;
@@ -69,14 +51,10 @@ function TurnsOverlay({
});
return unique.size;
}, [users]);
const shouldUsePreviewByLoad = turnModel
? Boolean(turnModel.showPreviewReason)
: isTurnsMode && totalDrivers > totalRovers;
const shouldUsePreviewByLoad = isTurnsMode && totalDrivers > totalRovers;
const isPreSwitchWindow =
isTurnsMode && isNextDriver && msUntilTurn != null && msUntilTurn <= 5000 && msUntilTurn > 0;
const showNotTurnNotice = turnModel
? Boolean(turnModel.showNotTurnNotice ?? (isTurnsMode && !isActiveDriver))
: isTurnsMode && !isActiveDriver;
const showNotTurnNotice = isTurnsMode && !isActiveDriver;
const showPreviewReason = showNotTurnNotice && !isPreSwitchWindow && shouldUsePreviewByLoad;
const turnSeconds =
msUntilTurn != null && Number.isFinite(msUntilTurn) ? Math.max(0, Math.ceil(msUntilTurn / 1000)) : null;
@@ -105,12 +83,9 @@ function TurnsOverlay({
const turnTimerFlashActive = noticeFlashActive;
useEffect(() => {
/*
Rover turns and PTZ turns now arrive through the same render path. Use the
normalized isTurnsMode flag here instead of checking the server's rover
mode directly, otherwise PTZ can render the notice but never trigger the
"your turn" cue when camera ownership changes.
*/
// The legacy overlay still follows the server's turns mode; ownership
// and deadlines now come from the same turn object as the new HUD.
if (!isTurnsMode) {
lastTurnRef.current = { initialized: false, roverId: null, activeDriverId: null };
/*
@@ -5,7 +5,7 @@ import { FaVideo } from 'react-icons/fa';
import { useControlActions, useControlSelector } from '../../../../controls/index.js';
import ControlHint from '../../../ControlHint/index.jsx';
import useCanControlRover from '../../../../hooks/useCanControlRover.js';
import { useDriverLayout } from '../../../../layouts/driver/DriverLayoutContext.jsx';
import { useLayout } from '../../../../layouts/LayoutContext.jsx';
import KeyPill from '../../../vip/VipAudioUploadCard/KeyPill.jsx';
import ChatExpansion from './ChatExpansion.jsx';
import CornerPodToggle from './CornerPodToggle.jsx';
@@ -28,7 +28,7 @@ function pointOnArc(fraction) {
}
export default function BottomRightPod({ roverId }) {
const layout = useDriverLayout();
const layout = useLayout();
const [open, setOpen] = usePodVisibility('camera', true);
const camera = useControlSelector((control) => control.state.camera);
const dockAssistActive = useControlSelector((control) => Boolean(control.state.manualDockAssist?.active));
@@ -12,7 +12,7 @@ export default function TopLeftPod({ turns, compact = false }) {
const [timerOpen, setTimerOpen] = usePodVisibility('turnTimer', true);
const [nameOpen, setNameOpen] = usePodVisibility('roverName', true);
const lastControlIntentAt = useControlSelector((control) => control.state.lastControlIntentAt);
const { target, labels, enabled: turnActive, queueLength, turnsAhead, isActive: isCurrentTurn } = turns;
const { target, labels, enabled: turnActive, queueLength, turnsAhead, ownsControl: isCurrentTurn } = turns;
const deadline = turns.deadline;
const idleDeadline = turns.idleDeadline;
const durationSeconds = turns.durationMs / 1000;
@@ -6,10 +6,10 @@ import TopRightPod from './TopRightPod.jsx';
import BottomLeftPod from './BottomLeftPod.jsx';
import BottomRightPod from './BottomRightPod.jsx';
import AccessoriesExpansion from './AccessoriesExpansion.jsx';
import { useDriverLayout } from '../../../../layouts/driver/DriverLayoutContext.jsx';
import { useLayout } from '../../../../layouts/LayoutContext.jsx';
export default function CornerPods({ roverId }) {
const layout = useDriverLayout();
const layout = useLayout();
const turns = useSessionSelector((state) => state.session?.roster
?.find((rover) => String(rover.id) === String(roverId))?.turn);
const showPhysicalControlPods = layout === 'desktop';
@@ -11,7 +11,7 @@ import { dockTelemetryEqual, selectDockTelemetry } from '../../../../context/tel
import { useManualDockAssist } from '../../../../features/manualDockAssist/useManualDockAssist.js';
import { useSettingsNamespace } from '../../../../settings/index.js';
import useCanControlRover from '../../../../hooks/useCanControlRover.js';
import { useDriverLayout } from '../../../../layouts/driver/DriverLayoutContext.jsx';
import { useLayout } from '../../../../layouts/LayoutContext.jsx';
import { useSessionSelector } from '../../../../context/SessionContext.jsx';
import KeyPill from '../../../vip/VipAudioUploadCard/KeyPill.jsx';
import ExpansionPanel from '../CornerPods/ExpansionPanel.jsx';
@@ -250,7 +250,7 @@ function UndockTransitionGhost({ onFinish }) {
}
export default function DockingHud({ roverId }) {
const layout = useDriverLayout();
const layout = useLayout();
const actions = useControlActions();
const dockTelemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
// This replaces ManualDockAssistOverlay as the current HUD's one lifecycle owner. It preserves the
@@ -170,8 +170,6 @@ export function ExternalInstancesCompact({ onBrowse = null }) {
key={remote.url}
title={remote.instance?.name || remote.url}
roster={remote.roster}
turnQueues={remote.turnQueues}
users={remote.users}
externalInstance={remote}
disabledOverlay={getRemoteAvailability(remote).blocked ? getRemoteAvailability(remote).overlay : ''}
/>
@@ -221,8 +219,6 @@ function InterInstanceCards({ instances, centered = false, singleColumn = false
<RemoteMediaStrip remote={remote} />
<RoverQueuesPanel
roster={remote.roster}
turnQueues={remote.turnQueues}
users={remote.users}
externalInstance={remote}
disabledOverlay={getRemoteAvailability(remote).blocked ? getRemoteAvailability(remote).overlay : ''}
/>
+2 -2
View File
@@ -11,7 +11,7 @@ import BottomSensorHud from '../HudOverlays/newgen/BottomSensorHud/index.jsx';
import { useSessionSelector } from '../../context/SessionContext.jsx';
import { useControlSelector } from '../../controls/index.js';
import { useDriverVideoModePolicy } from '../../hooks/useDriverVideoModePolicy.js';
import { useDriverLayout } from '../../layouts/driver/DriverLayoutContext.jsx';
import { useLayout } from '../../layouts/LayoutContext.jsx';
import EmptyDriverVideoNotice from '../DriverVideo/EmptyDriverVideoNotice.jsx';
// Mobile keeps the same HUD composition and coordinate system as desktop, but its
@@ -21,7 +21,7 @@ import EmptyDriverVideoNotice from '../DriverVideo/EmptyDriverVideoNotice.jsx';
const MOBILE_HUD_SCALE = 0.68;
export default function NewDriveVideo() {
const layout = useDriverLayout();
const layout = useLayout();
const mobileHud = layout !== 'desktop';
const roverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
const videoMode = useDriverVideoModePolicy(roverId);
+5 -11
View File
@@ -7,7 +7,6 @@ import { PTZ_CAMERA_ID } from '../PtzLiveVideo/index.jsx';
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
import { useSharedClock } from '../../hooks/useSharedClock.js';
import { isFeatureEnabled } from '../../lib/features.js';
import useQueueUserLookup from '../../hooks/useQueueUserLookup.js';
const PTZ_DEFAULT_COLOR = '#387bf8';
function formatRemaining(deadline, now) {
@@ -24,13 +23,11 @@ export default function PtzQueueCard() {
const selfId = useSessionSelector((state) => state.session?.socketId || null);
const { setOperatingMode } = useSessionActions();
const navigate = useNavigate();
const lookupUser = useQueueUserLookup(ptz?.turn);
const { queue, currentId, nextId } = ptz?.turn || {};
const now = useSharedClock(1000, Boolean(ptz?.turn?.deadline));
const [pending, setPending] = useState(false);
const canUse = Boolean(ptz?.permissions?.canEnter);
const isParticipant = Boolean(ptz?.turn?.isActive || ptz?.turn?.turnsAhead);
const timerLabel = ptz?.turn?.isActive && ptz?.turn?.deadline ? `${formatRemaining(ptz.turn.deadline, now)} left` : '';
const isParticipant = Boolean(ptz?.turn?.ownsControl || ptz?.turn?.turnsAhead);
const timerLabel = ptz?.turn?.ownsControl && ptz?.turn?.deadline ? `${formatRemaining(ptz.turn.deadline, now)} left` : '';
if (!featureEnabled) return null;
@@ -77,16 +74,13 @@ export default function PtzQueueCard() {
name: ptz?.name || 'PTZ Camera',
color: ptz?.color || PTZ_DEFAULT_COLOR,
}}
queue={queue}
currentId={currentId}
nextId={nextId}
turn={ptz?.turn}
selfId={selfId}
lookupUser={lookupUser}
canClick={canUse && !pending}
pending={pending}
buttonLabel={actionLabel}
batteryLabel={ptz?.turn?.isActive ? 'LIVE' : ptz?.turn?.turnsAhead ? `#${ptz.turn.turnsAhead}` : '--'}
batteryClassName={ptz?.turn?.isActive ? 'text-emerald-300' : ptz?.turn?.turnsAhead ? 'text-sky-300' : 'text-slate-400'}
batteryLabel={ptz?.turn?.ownsControl ? 'LIVE' : ptz?.turn?.turnsAhead ? `#${ptz.turn.turnsAhead}` : '--'}
batteryClassName={ptz?.turn?.ownsControl ? 'text-emerald-300' : ptz?.turn?.turnsAhead ? 'text-sky-300' : 'text-slate-400'}
timerLabel={timerLabel}
onRequest={handleRequest}
showAction={canUse}
+8 -30
View File
@@ -21,36 +21,20 @@ function roleColors(role) {
}
}
function formatQueueUserLabel(user, selfId) {
/*
Queue chips need to be readable even when a socket has no nickname yet.
Keeping the socket-prefix fallback here means rover queues and PTZ queues
degrade identically instead of each target inventing its own anonymous label.
*/
if (!user) return '';
const base = user.nickname || user.label || user.socketId?.slice(0, 6) || 'unknown';
if (user.socketId && user.socketId === selfId) {
return `${base} (you)`;
}
return base;
}
export function QueueUserChips({
targetId,
queue = [],
currentId = null,
nextId = null,
turn,
selfId = null,
lookupUser,
}) {
if (!queue.length) {
const { queue = [], currentId, nextId } = turn || {};
if (!queue?.length) {
return <p className="text-[0.7rem] text-slate-500">No queue.</p>;
}
return (
<div className="flex flex-wrap items-center gap-0.5">
{queue.map((socketId, idx) => {
const user = lookupUser?.(socketId) || { socketId, nickname: null, role: null };
{queue.map((user, idx) => {
const { socketId } = user;
const isCurrent = socketId === currentId;
const isNext = Boolean(nextId && socketId === nextId && !isCurrent);
/*
@@ -69,7 +53,7 @@ export function QueueUserChips({
className={`flex items-center gap-0.5 rounded-sm px-1 text-[0.7rem] ${highlightClass}`}
>
<span className={`${roleColors(user.role)} font-semibold`}>
{formatQueueUserLabel(user, selfId)}
{user.name}{socketId === selfId ? ' (you)' : ''}
</span>
{isCurrent && <span className="text-[0.65rem] text-slate-200">now</span>}
{isNext && <span className="text-[0.65rem] text-emerald-100">next</span>}
@@ -82,11 +66,8 @@ export function QueueUserChips({
export default function QueueTargetRow({
target,
queue = [],
currentId = null,
nextId = null,
turn,
selfId = null,
lookupUser,
canClick = false,
pending = false,
locked = false,
@@ -164,11 +145,8 @@ export default function QueueTargetRow({
</div>
<QueueUserChips
targetId={targetId}
queue={queue}
currentId={currentId}
nextId={nextId}
turn={turn}
selfId={selfId}
lookupUser={lookupUser}
/>
</div>
{showAction ? (
@@ -1,7 +1,6 @@
// constants
// Purpose: Defines the constants module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
export const RESTART_DELAY_MS = 2000;
export const UNMUTE_RETRY_MS = 3000;
export const AUDIO_RETRY_MS = 3000;
export const BRUSH_CURRENT_THRESHOLD_MA = 40;
@@ -3,7 +3,6 @@
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
import useQueueUserLookup from '../../hooks/useQueueUserLookup.js';
import { useSharedClock } from '../../hooks/useSharedClock.js';
import CardFrame from '../CardFrame/index.jsx';
import QueueTargetRow from '../QueueTargetRow/index.jsx';
@@ -97,16 +96,12 @@ function ScrollableQueueContent({ enabled = false, children }) {
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 { value: pageSettings } = useSettingsNamespace('page', { interInstanceTransferSettings: true });
const selfId = useSessionSelector((state) => state.session?.socketId || null);
@@ -125,8 +120,6 @@ export default function RoverQueuesPanel({
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'),
@@ -137,8 +130,8 @@ export default function RoverQueuesPanel({
[role],
);
const hasDeadlines = useMemo(
() => Object.values(turnQueues || {}).some((info) => info?.deadline || info?.idleDeadline),
[turnQueues],
() => roster.some((rover) => rover.turn?.deadline || rover.turn?.idleDeadline),
[roster],
);
/*
Queue timers are shown in whole seconds, and several queue panels can be
@@ -147,14 +140,6 @@ export default function RoverQueuesPanel({
*/
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;
@@ -182,7 +167,6 @@ export default function RoverQueuesPanel({
}
}
const lookupUser = useQueueUserLookup(null, users);
async function handleRebootOwnRover() {
if (rebootPending) return;
@@ -224,14 +208,13 @@ export default function RoverQueuesPanel({
>
<ScrollableQueueContent enabled={fillHeight}>
<div className="relative space-y-0.5">
{rosterItems.length === 0 ? (
{roster.length === 0 ? (
<p className="text-sm text-slate-500">No rovers registered.</p>
) : (
<ul className="space-y-0.5 text-sm">
{rosterItems.map((rover) => {
{roster.map((rover) => {
const roverId = String(rover.id);
const info = turnQueues?.[roverId] || null;
const queue = info?.queue || [];
const info = rover.turn;
/*
A turn handoff and an inactivity skip are separate server
deadlines. Keep them separate through formatting so an idle
@@ -275,11 +258,8 @@ export default function RoverQueuesPanel({
<QueueTargetRow
key={rover.id}
target={{ ...rover, rover, roverId, id: roverId }}
queue={queue}
currentId={currentId}
nextId={nextId}
turn={info}
selfId={selfId}
lookupUser={lookupUser}
canClick={canClickRow}
pending={Boolean(pending[roverId])}
locked={locked}
@@ -25,7 +25,6 @@ function useAudio(src, volume = 1) {
export default function TurnAlertListener() {
const socketId = useSessionSelector((state) => state.session?.socketId || null);
const assignments = useSessionSelector((state) => state.session?.turnQueues || {});
const roster = useSessionSelector((state) => state.session?.roster || []);
const { pushAlert } = useSessionActions();
const { value: audioSettings } = useSettingsNamespace('audio', AUDIO_SETTINGS_DEFAULTS);
@@ -38,18 +37,19 @@ export default function TurnAlertListener() {
useEffect(() => {
if (!socketId) return;
const newlyMine = [];
Object.entries(assignments).forEach(([roverId, info]) => {
if (!info || !info.current || info.current !== socketId) return;
roster.forEach((rover) => {
const roverId = String(rover.id);
if (!rover.turn?.ownsControl) return;
if (!seenRoversRef.current.has(roverId)) {
newlyMine.push(roverId);
seenRoversRef.current.add(roverId);
}
});
Object.keys(assignments).forEach((roverId) => {
if (!assignments[roverId] || assignments[roverId].current !== socketId) {
for (const roverId of seenRoversRef.current) {
if (!roster.some((rover) => String(rover.id) === roverId && rover.turn?.ownsControl)) {
seenRoversRef.current.delete(roverId);
}
});
}
if (newlyMine.length === 0) return;
newlyMine.forEach((roverId) => {
const roverName = roster.find((r) => String(r.id) === String(roverId))?.name || roverId;
@@ -60,7 +60,7 @@ export default function TurnAlertListener() {
});
});
playSound();
}, [assignments, playSound, pushAlert, roster, socketId]);
}, [playSound, pushAlert, roster, socketId]);
return null;
}
+8 -22
View File
@@ -73,8 +73,8 @@ export default function UserListPanel({
const users = useSessionSelector((state) => state.session?.users ?? []);
const selfId = useSessionSelector((state) => state.session?.socketId || null);
const mode = useSessionSelector((state) => state.session?.mode || null);
const turnQueues = useSessionSelector((state) => state.session?.turnQueues || {});
const roster = useSessionSelector((state) => state.session?.roster || []);
const queuedRovers = roster.filter((rover) => rover.turn?.queue != null);
const isTurnsMode = mode === 'turns';
const [turnView, setTurnView] = useState('queues');
@@ -98,11 +98,6 @@ export default function UserListPanel({
[roster],
);
const lookupUser = useCallback(
(socketId) => users.find((u) => u.socketId === socketId) || { socketId, nickname: null, role: null },
[users],
);
const secondsRemaining = useCallback((deadline) => {
if (!deadline) return null;
const ms = deadline - Date.now();
@@ -165,7 +160,7 @@ export default function UserListPanel({
return (
<CardFrame
title={!hideHeader ? (isTurnsMode ? (showQueuesSection ? 'Turn queues' : 'Users') : 'Users') : ''}
meta={!hideHeader ? (showQueuesSection && isTurnsMode ? Object.keys(turnQueues || {}).length : sorted.length) : null}
meta={!hideHeader ? (showQueuesSection && isTurnsMode ? queuedRovers.length : sorted.length) : null}
hideHeader={hideHeader}
fillHeight={fillHeight}
className={className}
@@ -209,21 +204,14 @@ export default function UserListPanel({
) : null}
{showQueuesSection ? (
<div className={`space-y-0.5 px-0 pb-0 ${turnsListClass} ${compact ? 'text-[0.8rem]' : ''}`}>
{Object.keys(turnQueues || {}).length === 0 ? (
{queuedRovers.length === 0 ? (
<p className="text-sm text-slate-500">No turn queues yet.</p>
) : (
Object.entries(turnQueues).map(([roverId, info]) => {
queuedRovers.map(({ id: roverId, turn: info }) => {
const queue = info?.queue || [];
const deadline = info?.idleDeadline || info?.deadline || null;
const remaining = secondsRemaining(deadline);
const currentId = info?.current || null;
const currentIdx = currentId ? queue.findIndex((id) => id === currentId) : -1;
const nextId =
queue.length > 1
? currentIdx >= 0
? queue[(currentIdx + 1) % queue.length]
: queue[0]
: null;
const { currentId, nextId } = info;
return (
<div key={roverId} className={`flex flex-col gap-0.5 ${compact ? 'text-[0.8rem] py-0.25' : 'text-sm'}`}>
<div className="flex items-center gap-0.5">
@@ -240,11 +228,10 @@ export default function UserListPanel({
<p className="text-[0.75rem] text-slate-500">No drivers queued.</p>
) : (
<div className="flex flex-wrap items-center gap-0.5">
{queue.map((socketId, idx) => {
const user = lookupUser(socketId);
{queue.map((user, idx) => {
const { socketId } = user;
const isCurrent = socketId === currentId;
const isNext = Boolean(nextId && socketId === nextId && !isCurrent);
const isSelf = Boolean(selfId && socketId === selfId);
const isAdmin =
user.role === 'admin' || user.role === 'lockdown';
const highlightClass = isCurrent
@@ -258,10 +245,9 @@ export default function UserListPanel({
className={`flex items-center gap-0.5 rounded-sm px-1 ${compact ? 'text-[0.7rem]' : 'text-[0.8rem]'} ${highlightClass}`}
>
<span className={`${roleColors(user.role)} font-semibold`}>
{formatLabel(user, selfId)}
{user.name}{socketId === selfId ? ' (you)' : ''}
</span>
{isAdmin && <span className="text-[0.7rem] text-amber-200">★</span>}
{/* {isSelf && <span className="text-[0.7rem] text-white">YOU</span>} */}
{isCurrent && <span className="text-[0.7rem] text-slate-200">now</span>}
{isNext && <span className="text-[0.7rem] text-emerald-100">next</span>}
</span>