mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
slopfixing / issue 007
This commit is contained in:
@@ -53,21 +53,44 @@ export default function AlertFeed({ scale = 1 }) {
|
||||
|
||||
const latest = useMemo(() => alerts.slice(-12).map((alert) => ({ alert, key: buildKey(alert) })), [alerts]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!latest.length) return undefined;
|
||||
const interval = setInterval(() => setNow(Date.now()), 200);
|
||||
return () => clearInterval(interval);
|
||||
}, [latest.length]);
|
||||
const { visible, nextExpiryAt } = useMemo(() => {
|
||||
const visibleByKey = new Map();
|
||||
let soonestExpiryAt = null;
|
||||
|
||||
const visibleByKey = new Map();
|
||||
latest.forEach((item) => {
|
||||
const age = now - (item.alert.receivedAt ?? item.alert.timestamp ?? 0);
|
||||
const lifetimeMs = Number.isFinite(item.alert.lifetimeMs) ? item.alert.lifetimeMs : LIFETIME_MS;
|
||||
if (age <= lifetimeMs) {
|
||||
visibleByKey.set(item.key, { ...item, age });
|
||||
}
|
||||
});
|
||||
const visible = Array.from(visibleByKey.values()).slice(-3);
|
||||
latest.forEach((item) => {
|
||||
const receivedAt = item.alert.receivedAt ?? item.alert.timestamp ?? 0;
|
||||
const age = now - receivedAt;
|
||||
const lifetimeMs = Number.isFinite(item.alert.lifetimeMs) ? item.alert.lifetimeMs : LIFETIME_MS;
|
||||
const expiryAt = receivedAt + lifetimeMs;
|
||||
|
||||
if (age <= lifetimeMs) {
|
||||
visibleByKey.set(item.key, { ...item, age });
|
||||
/*
|
||||
Alerts do not render a live progress value, so polling every 200ms is
|
||||
unnecessary. Tracking the earliest expiry lets this component sleep
|
||||
until one visible toast actually needs to disappear.
|
||||
*/
|
||||
soonestExpiryAt = soonestExpiryAt == null ? expiryAt : Math.min(soonestExpiryAt, expiryAt);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
visible: Array.from(visibleByKey.values()).slice(-3),
|
||||
nextExpiryAt: soonestExpiryAt,
|
||||
};
|
||||
}, [latest, now]);
|
||||
|
||||
useEffect(() => {
|
||||
if (nextExpiryAt == null) return undefined;
|
||||
/*
|
||||
The small padding avoids waking a few milliseconds before the browser's
|
||||
current Date.now value crosses the expiry boundary. Without it, React can
|
||||
render the same alert once more and schedule a second near-immediate timer.
|
||||
*/
|
||||
const delayMs = Math.max(0, nextExpiryAt - Date.now()) + 25;
|
||||
const timer = setTimeout(() => setNow(Date.now()), delayMs);
|
||||
return () => clearTimeout(timer);
|
||||
}, [nextExpiryAt]);
|
||||
|
||||
if (!visible.length) return null;
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSessionSelector } from '../../../context/SessionContext.jsx';
|
||||
import { useControlSelector } from '../../../controls/index.js';
|
||||
import { useSharedClock } from '../../../hooks/useSharedClock.js';
|
||||
import SocialButton from '../../SocialButton/index.jsx';
|
||||
|
||||
function TurnsOverlay({
|
||||
@@ -16,7 +17,6 @@ function TurnsOverlay({
|
||||
const socketId = useSessionSelector((state) => state.session?.socketId || null);
|
||||
const activeDrivers = useSessionSelector((state) => state.session?.activeDrivers ?? {});
|
||||
const lastControlIntentAt = useControlSelector((control) => control.state.lastControlIntentAt);
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
const [showTurnCue, setShowTurnCue] = useState(false);
|
||||
const [turnCueStartAt, setTurnCueStartAt] = useState(null);
|
||||
const [noticeFlashActive, setNoticeFlashActive] = useState(false);
|
||||
@@ -32,19 +32,20 @@ function TurnsOverlay({
|
||||
const turnInfo = effectiveRoverId ? turnQueues?.[effectiveRoverId] : null;
|
||||
const activeDriverId = effectiveRoverId ? activeDrivers?.[effectiveRoverId] : null;
|
||||
const isActiveDriver = Boolean(socketId && activeDriverId === socketId);
|
||||
const isTurnsMode = mode === 'turns';
|
||||
const now = useSharedClock(1000, isTurnsMode);
|
||||
const nextDriverId = useMemo(() => {
|
||||
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?.queue, turnInfo?.current]);
|
||||
}, [turnInfo]);
|
||||
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 isTurnsMode = mode === 'turns';
|
||||
const totalRovers = roster.length;
|
||||
const totalDrivers = useMemo(() => {
|
||||
const unique = new Set();
|
||||
@@ -80,12 +81,6 @@ function TurnsOverlay({
|
||||
const showCountdown = isActiveDriver && typeof idleSkipSeconds === 'number';
|
||||
const turnTimerFlashActive = noticeFlashActive;
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== 'turns') return undefined;
|
||||
const timer = setInterval(() => setNow(Date.now()), 250);
|
||||
return () => clearInterval(timer);
|
||||
}, [mode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== 'turns') {
|
||||
setShowTurnCue(false);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// Mode Gate Overlay
|
||||
// Purpose: Defines the Mode Gate Overlay 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 { useEffect, useMemo, useState } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import AuthPanel from '../AuthPanel/index.jsx';
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { useSharedClock } from '../../hooks/useSharedClock.js';
|
||||
import SocialButton from '../SocialButton/index.jsx';
|
||||
import ChatPanel from '../ChatPanel/index.jsx';
|
||||
|
||||
@@ -30,13 +31,18 @@ export default function ModeGateOverlay() {
|
||||
const mode = useSessionSelector((state) => state.session?.mode || null);
|
||||
const role = useSessionSelector((state) => state.session?.role || null);
|
||||
const reason = useSessionSelector((state) => state.session?.adminReason?.text || '');
|
||||
const reasonUpdatedAt = useSessionSelector((state) => state.session?.adminReason?.updatedAt || null);
|
||||
const timezone = useSessionSelector((state) => state.session?.timezone || 'UTC');
|
||||
const restricted = RESTRICTED_MODES.has(mode);
|
||||
const privileged = mode === 'lockdown' ? LOCKDOWN_ROLES.has(role) : PRIVILEGED_ROLES.has(role);
|
||||
const [now, setNow] = useState(() => new Date());
|
||||
/*
|
||||
The overlay is mounted for the whole app, but the server-time display is
|
||||
only visible while access is actually blocked. Gating the shared clock here
|
||||
prevents the hidden overlay from registering a permanent interval.
|
||||
*/
|
||||
const nowMs = useSharedClock(1000, restricted && !privileged);
|
||||
|
||||
const serverTime = useMemo(() => {
|
||||
const now = new Date(nowMs);
|
||||
try {
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: timezone,
|
||||
@@ -44,15 +50,10 @@ export default function ModeGateOverlay() {
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
}).format(now);
|
||||
} catch (err) {
|
||||
} catch {
|
||||
return now.toLocaleTimeString();
|
||||
}
|
||||
}, [now, timezone]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setNow(new Date()), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
}, [nowMs, timezone]);
|
||||
|
||||
if (!restricted || privileged) {
|
||||
return null;
|
||||
@@ -73,11 +74,6 @@ export default function ModeGateOverlay() {
|
||||
{reason ? reason : 'No reason set.'}
|
||||
</p>
|
||||
<p className="text-center text-sm text-slate-300">Server time: {serverTime}</p>
|
||||
{/* {reasonUpdatedAt ? (
|
||||
<p className="text-[0.7rem] text-slate-500">
|
||||
Updated {new Date(reasonUpdatedAt).toLocaleString()}
|
||||
</p>
|
||||
) : null} */}
|
||||
</div>
|
||||
<div className="surface-muted">
|
||||
<AuthPanel />
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { useSharedClock } from '../../hooks/useSharedClock.js';
|
||||
import { useSettingsNamespace } from '../../settings/index.js';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import RoverLabel from '../RoverLabel/index.jsx';
|
||||
@@ -51,7 +52,6 @@ export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHei
|
||||
const [title, setTitle] = useState('');
|
||||
const [titleDirty, setTitleDirty] = useState(false);
|
||||
const [includeSidebar, setIncludeSidebar] = useState(true);
|
||||
const [remainingMs, setRemainingMs] = useState(0);
|
||||
const [activeJobId, setActiveJobId] = useState(null);
|
||||
const [dismissedPanelReplayId, setDismissedPanelReplayId] = useState(null);
|
||||
// Settings keys include the panel id because the same replay source control is
|
||||
@@ -138,19 +138,22 @@ export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHei
|
||||
};
|
||||
}, [sources]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!replayState?.lastTriggeredAt || !replayState?.cooldownMs) {
|
||||
setRemainingMs(0);
|
||||
return undefined;
|
||||
}
|
||||
const update = () => {
|
||||
const next = replayState.lastTriggeredAt + replayState.cooldownMs - Date.now();
|
||||
setRemainingMs(Math.max(0, next));
|
||||
};
|
||||
update();
|
||||
const interval = setInterval(update, 250);
|
||||
return () => clearInterval(interval);
|
||||
}, [replayState?.lastTriggeredAt, replayState?.cooldownMs, replayState?.remainingMs]);
|
||||
const hasReplayCooldown = Boolean(replayState?.lastTriggeredAt && replayState?.cooldownMs);
|
||||
const replayCooldownEndsAt = hasReplayCooldown
|
||||
? replayState.lastTriggeredAt + replayState.cooldownMs
|
||||
: 0;
|
||||
const cooldownNow = useSharedClock(1000, hasReplayCooldown);
|
||||
const remainingMs = useMemo(() => {
|
||||
if (!hasReplayCooldown) return 0;
|
||||
/*
|
||||
The button only shows whole seconds, so a shared one-second clock gives the
|
||||
same useful information without each mounted replay panel owning a 250ms
|
||||
interval. The exact server cooldown still decides whether the action is
|
||||
accepted; this value is only the local disabled-state/display estimate.
|
||||
*/
|
||||
const next = replayCooldownEndsAt - cooldownNow;
|
||||
return Math.max(0, next);
|
||||
}, [cooldownNow, hasReplayCooldown, replayCooldownEndsAt]);
|
||||
|
||||
const replayDisabled = busy || mode === 'lockdown' || remainingMs > 0 || !selected.length;
|
||||
const selectedSet = useMemo(() => {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// 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 { useEffect, useMemo, useState } from 'react';
|
||||
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 RoverLabel from '../RoverLabel/index.jsx';
|
||||
|
||||
@@ -61,7 +62,6 @@ export default function RoverQueuesPanel({ title = 'Rovers' }) {
|
||||
const { requestControl, rebootOwnRover } = useSessionActions();
|
||||
const [pending, setPending] = useState({});
|
||||
const [rebootPending, setRebootPending] = useState(false);
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
|
||||
const canRequest = useMemo(() => role && role !== 'spectator', [role]);
|
||||
const adminCapable = useMemo(
|
||||
@@ -72,14 +72,12 @@ export default function RoverQueuesPanel({ title = 'Rovers' }) {
|
||||
() => Object.values(turnQueues || {}).some((info) => info?.deadline || info?.idleDeadline),
|
||||
[turnQueues],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasDeadlines) return undefined;
|
||||
const timer = setInterval(() => {
|
||||
setNow(Date.now());
|
||||
}, 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [hasDeadlines]);
|
||||
/*
|
||||
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)));
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { useSessionSelector } from '../context/SessionContext.jsx';
|
||||
import { useSharedClock } from './useSharedClock.js';
|
||||
|
||||
export function useDriverVideoModePolicy(roverId) {
|
||||
const mode = useSessionSelector((state) => state.session?.mode || null);
|
||||
@@ -8,7 +9,13 @@ export function useDriverVideoModePolicy(roverId) {
|
||||
const turnQueues = useSessionSelector((state) => state.session?.turnQueues ?? {});
|
||||
const socketId = useSessionSelector((state) => state.session?.socketId || null);
|
||||
const activeDrivers = useSessionSelector((state) => state.session?.activeDrivers ?? {});
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
const isTurnsMode = mode === 'turns';
|
||||
/*
|
||||
This policy only switches preview/full video around a multi-second turn
|
||||
boundary. A shared one-second clock is responsive enough for that UI policy
|
||||
and avoids running a separate 250ms timer beside the TurnsOverlay countdown.
|
||||
*/
|
||||
const now = useSharedClock(1000, isTurnsMode);
|
||||
|
||||
const turnInfo = roverId ? turnQueues?.[roverId] || null : null;
|
||||
const activeDriverId = roverId ? activeDrivers?.[roverId] || null : null;
|
||||
@@ -19,11 +26,10 @@ export function useDriverVideoModePolicy(roverId) {
|
||||
const idx = queue.findIndex((id) => id === turnInfo.current);
|
||||
if (idx === -1) return queue[0] || null;
|
||||
return queue[(idx + 1) % queue.length] || null;
|
||||
}, [turnInfo?.queue, turnInfo?.current]);
|
||||
}, [turnInfo]);
|
||||
const isNextDriver = Boolean(socketId && nextDriverId === socketId);
|
||||
const deadline = turnInfo?.deadline || null;
|
||||
const msUntilTurn = deadline ? deadline - now : null;
|
||||
const isTurnsMode = mode === 'turns';
|
||||
const totalRovers = roster.length;
|
||||
const totalDrivers = useMemo(() => {
|
||||
const unique = new Set();
|
||||
@@ -43,11 +49,5 @@ export function useDriverVideoModePolicy(roverId) {
|
||||
const showNotTurnNotice = isTurnsMode && !isActiveDriver;
|
||||
const forceSnapshotByTurnPolicy = showNotTurnNotice && !isPreSwitchWindow && shouldUsePreviewByLoad;
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== 'turns') return undefined;
|
||||
const timer = setInterval(() => setNow(Date.now()), 250);
|
||||
return () => clearInterval(timer);
|
||||
}, [mode]);
|
||||
|
||||
return forceSnapshotByTurnPolicy ? 'snapshot' : null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
// Shared Clock Hook
|
||||
// Purpose: Lets display-only countdown components share one browser interval per cadence.
|
||||
// Scope: Keeps timers out of control/safety paths; callers opt in only for UI text that can tolerate coarse updates.
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const clockStores = new Map();
|
||||
|
||||
function nowMs() {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
function normalizeDelay(delayMs) {
|
||||
const delay = Number(delayMs);
|
||||
if (!Number.isFinite(delay) || delay <= 0) return 1000;
|
||||
return Math.max(16, Math.round(delay));
|
||||
}
|
||||
|
||||
function getStore(delayMs) {
|
||||
const delay = normalizeDelay(delayMs);
|
||||
const existing = clockStores.get(delay);
|
||||
if (existing) return existing;
|
||||
|
||||
const store = {
|
||||
delay,
|
||||
timer: null,
|
||||
visibilityHandler: null,
|
||||
current: nowMs(),
|
||||
listeners: new Set(),
|
||||
};
|
||||
clockStores.set(delay, store);
|
||||
return store;
|
||||
}
|
||||
|
||||
function stopStore(store) {
|
||||
if (store.timer) {
|
||||
clearInterval(store.timer);
|
||||
store.timer = null;
|
||||
}
|
||||
if (store.visibilityHandler && typeof document !== 'undefined') {
|
||||
document.removeEventListener('visibilitychange', store.visibilityHandler);
|
||||
store.visibilityHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
function publish(store) {
|
||||
store.current = nowMs();
|
||||
store.listeners.forEach((listener) => listener(store.current));
|
||||
}
|
||||
|
||||
function ensureVisibilityHandler(store) {
|
||||
if (store.visibilityHandler || typeof document === 'undefined') return;
|
||||
store.visibilityHandler = () => {
|
||||
if (!store.listeners.size) return;
|
||||
if (document.visibilityState === 'visible') {
|
||||
/*
|
||||
Hidden tabs do not need display-only countdown work. When the tab comes
|
||||
back, publish immediately so labels catch up before the next interval.
|
||||
*/
|
||||
publish(store);
|
||||
startStore(store);
|
||||
return;
|
||||
}
|
||||
if (store.timer) {
|
||||
clearInterval(store.timer);
|
||||
store.timer = null;
|
||||
}
|
||||
};
|
||||
document.addEventListener('visibilitychange', store.visibilityHandler);
|
||||
}
|
||||
|
||||
function startStore(store) {
|
||||
if (store.timer || !store.listeners.size) return;
|
||||
ensureVisibilityHandler(store);
|
||||
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return;
|
||||
/*
|
||||
One interval fans out to every mounted consumer using the same cadence. The
|
||||
previous pattern created independent intervals in each countdown component,
|
||||
which meant several panels could wake the main thread separately even though
|
||||
they only needed the same "current time" value for human-readable labels.
|
||||
*/
|
||||
store.timer = setInterval(() => {
|
||||
publish(store);
|
||||
}, store.delay);
|
||||
}
|
||||
|
||||
function subscribe(store, listener) {
|
||||
store.listeners.add(listener);
|
||||
startStore(store);
|
||||
|
||||
return () => {
|
||||
store.listeners.delete(listener);
|
||||
/*
|
||||
Stop the interval as soon as the last consumer leaves. This matters for
|
||||
tab panels and overlays because hidden or unmounted UI should not keep a
|
||||
display clock alive just because another render path created it earlier.
|
||||
*/
|
||||
if (!store.listeners.size) {
|
||||
stopStore(store);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function useSharedClock(delayMs = 1000, enabled = true) {
|
||||
const [now, setNow] = useState(() => nowMs());
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return undefined;
|
||||
const store = getStore(delayMs);
|
||||
/*
|
||||
Subscribing starts the shared interval but does not synchronously push
|
||||
state from inside the effect. React's newer lint rules flag synchronous
|
||||
effect-time state writes because they can cascade renders; the hook's
|
||||
initial Date.now state is already fresh for the first paint, and later
|
||||
updates arrive through the interval or visibility callback.
|
||||
*/
|
||||
return subscribe(store, setNow);
|
||||
}, [delayMs, enabled]);
|
||||
|
||||
return now;
|
||||
}
|
||||
Reference in New Issue
Block a user