- Updated {new Date(reasonUpdatedAt).toLocaleString()}
-
- ) : null} */}
diff --git a/webui/src/components/ReplaySourcesPanel/index.jsx b/webui/src/components/ReplaySourcesPanel/index.jsx
index f9f5110b..c653b523 100644
--- a/webui/src/components/ReplaySourcesPanel/index.jsx
+++ b/webui/src/components/ReplaySourcesPanel/index.jsx
@@ -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(() => {
diff --git a/webui/src/components/RoverQueuesPanel/index.jsx b/webui/src/components/RoverQueuesPanel/index.jsx
index 6325af53..508615a9 100644
--- a/webui/src/components/RoverQueuesPanel/index.jsx
+++ b/webui/src/components/RoverQueuesPanel/index.jsx
@@ -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)));
diff --git a/webui/src/hooks/useDriverVideoModePolicy.js b/webui/src/hooks/useDriverVideoModePolicy.js
index 0c356cdb..2d15b6f2 100644
--- a/webui/src/hooks/useDriverVideoModePolicy.js
+++ b/webui/src/hooks/useDriverVideoModePolicy.js
@@ -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;
}
diff --git a/webui/src/hooks/useSharedClock.js b/webui/src/hooks/useSharedClock.js
new file mode 100644
index 00000000..4442f772
--- /dev/null
+++ b/webui/src/hooks/useSharedClock.js
@@ -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;
+}