slopfixing / issue 007

This commit is contained in:
legop3
2026-06-12 14:02:27 -04:00
parent 01a0b112a1
commit 3efb31caae
10 changed files with 334 additions and 199 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -12,7 +12,7 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Roomba Rover" /> <meta name="apple-mobile-web-app-title" content="Roomba Rover" />
<title>Roomba Rover</title> <title>Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-Xz-F59g5.js"></script> <script type="module" crossorigin src="/assets/index-CCuQOOMm.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BVKa8Zph.css"> <link rel="stylesheet" crossorigin href="/assets/index-BVKa8Zph.css">
</head> </head>
<body> <body>
+37 -14
View File
@@ -53,21 +53,44 @@ export default function AlertFeed({ scale = 1 }) {
const latest = useMemo(() => alerts.slice(-12).map((alert) => ({ alert, key: buildKey(alert) })), [alerts]); const latest = useMemo(() => alerts.slice(-12).map((alert) => ({ alert, key: buildKey(alert) })), [alerts]);
useEffect(() => { const { visible, nextExpiryAt } = useMemo(() => {
if (!latest.length) return undefined; const visibleByKey = new Map();
const interval = setInterval(() => setNow(Date.now()), 200); let soonestExpiryAt = null;
return () => clearInterval(interval);
}, [latest.length]);
const visibleByKey = new Map(); latest.forEach((item) => {
latest.forEach((item) => { const receivedAt = item.alert.receivedAt ?? item.alert.timestamp ?? 0;
const age = now - (item.alert.receivedAt ?? item.alert.timestamp ?? 0); const age = now - receivedAt;
const lifetimeMs = Number.isFinite(item.alert.lifetimeMs) ? item.alert.lifetimeMs : LIFETIME_MS; const lifetimeMs = Number.isFinite(item.alert.lifetimeMs) ? item.alert.lifetimeMs : LIFETIME_MS;
if (age <= lifetimeMs) { const expiryAt = receivedAt + lifetimeMs;
visibleByKey.set(item.key, { ...item, age });
} if (age <= lifetimeMs) {
}); visibleByKey.set(item.key, { ...item, age });
const visible = Array.from(visibleByKey.values()).slice(-3); /*
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; if (!visible.length) return null;
@@ -1,6 +1,7 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'; import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useSessionSelector } from '../../../context/SessionContext.jsx'; import { useSessionSelector } from '../../../context/SessionContext.jsx';
import { useControlSelector } from '../../../controls/index.js'; import { useControlSelector } from '../../../controls/index.js';
import { useSharedClock } from '../../../hooks/useSharedClock.js';
import SocialButton from '../../SocialButton/index.jsx'; import SocialButton from '../../SocialButton/index.jsx';
function TurnsOverlay({ function TurnsOverlay({
@@ -16,7 +17,6 @@ function TurnsOverlay({
const socketId = useSessionSelector((state) => state.session?.socketId || null); const socketId = useSessionSelector((state) => state.session?.socketId || null);
const activeDrivers = useSessionSelector((state) => state.session?.activeDrivers ?? {}); const activeDrivers = useSessionSelector((state) => state.session?.activeDrivers ?? {});
const lastControlIntentAt = useControlSelector((control) => control.state.lastControlIntentAt); const lastControlIntentAt = useControlSelector((control) => control.state.lastControlIntentAt);
const [now, setNow] = useState(() => Date.now());
const [showTurnCue, setShowTurnCue] = useState(false); const [showTurnCue, setShowTurnCue] = useState(false);
const [turnCueStartAt, setTurnCueStartAt] = useState(null); const [turnCueStartAt, setTurnCueStartAt] = useState(null);
const [noticeFlashActive, setNoticeFlashActive] = useState(false); const [noticeFlashActive, setNoticeFlashActive] = useState(false);
@@ -32,19 +32,20 @@ function TurnsOverlay({
const turnInfo = effectiveRoverId ? turnQueues?.[effectiveRoverId] : null; const turnInfo = effectiveRoverId ? turnQueues?.[effectiveRoverId] : null;
const activeDriverId = effectiveRoverId ? activeDrivers?.[effectiveRoverId] : null; const activeDriverId = effectiveRoverId ? activeDrivers?.[effectiveRoverId] : null;
const isActiveDriver = Boolean(socketId && activeDriverId === socketId); const isActiveDriver = Boolean(socketId && activeDriverId === socketId);
const isTurnsMode = mode === 'turns';
const now = useSharedClock(1000, isTurnsMode);
const nextDriverId = useMemo(() => { const nextDriverId = useMemo(() => {
const queue = turnInfo?.queue || []; const queue = turnInfo?.queue || [];
if (!queue.length || !turnInfo?.current || queue.length <= 1) return null; if (!queue.length || !turnInfo?.current || queue.length <= 1) return null;
const idx = queue.findIndex((id) => id === turnInfo.current); const idx = queue.findIndex((id) => id === turnInfo.current);
if (idx === -1) return queue[0] || null; if (idx === -1) return queue[0] || null;
return queue[(idx + 1) % queue.length] || null; return queue[(idx + 1) % queue.length] || null;
}, [turnInfo?.queue, turnInfo?.current]); }, [turnInfo]);
const isNextDriver = Boolean(socketId && nextDriverId === socketId); const isNextDriver = Boolean(socketId && nextDriverId === socketId);
const deadline = turnInfo?.deadline || null; const deadline = turnInfo?.deadline || null;
const idleDeadline = turnInfo?.idleDeadline || null; const idleDeadline = turnInfo?.idleDeadline || null;
const msUntilTurn = deadline ? deadline - now : null; const msUntilTurn = deadline ? deadline - now : null;
const msUntilIdleSkip = idleDeadline ? idleDeadline - now : null; const msUntilIdleSkip = idleDeadline ? idleDeadline - now : null;
const isTurnsMode = mode === 'turns';
const totalRovers = roster.length; const totalRovers = roster.length;
const totalDrivers = useMemo(() => { const totalDrivers = useMemo(() => {
const unique = new Set(); const unique = new Set();
@@ -80,12 +81,6 @@ function TurnsOverlay({
const showCountdown = isActiveDriver && typeof idleSkipSeconds === 'number'; const showCountdown = isActiveDriver && typeof idleSkipSeconds === 'number';
const turnTimerFlashActive = noticeFlashActive; const turnTimerFlashActive = noticeFlashActive;
useEffect(() => {
if (mode !== 'turns') return undefined;
const timer = setInterval(() => setNow(Date.now()), 250);
return () => clearInterval(timer);
}, [mode]);
useEffect(() => { useEffect(() => {
if (mode !== 'turns') { if (mode !== 'turns') {
setShowTurnCue(false); setShowTurnCue(false);
+11 -15
View File
@@ -1,9 +1,10 @@
// Mode Gate Overlay // Mode Gate Overlay
// Purpose: Defines the Mode Gate Overlay module and the local helpers/components used in this file. // 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. // 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 AuthPanel from '../AuthPanel/index.jsx';
import { useSessionSelector } from '../../context/SessionContext.jsx'; import { useSessionSelector } from '../../context/SessionContext.jsx';
import { useSharedClock } from '../../hooks/useSharedClock.js';
import SocialButton from '../SocialButton/index.jsx'; import SocialButton from '../SocialButton/index.jsx';
import ChatPanel from '../ChatPanel/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 mode = useSessionSelector((state) => state.session?.mode || null);
const role = useSessionSelector((state) => state.session?.role || null); const role = useSessionSelector((state) => state.session?.role || null);
const reason = useSessionSelector((state) => state.session?.adminReason?.text || ''); 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 timezone = useSessionSelector((state) => state.session?.timezone || 'UTC');
const restricted = RESTRICTED_MODES.has(mode); const restricted = RESTRICTED_MODES.has(mode);
const privileged = mode === 'lockdown' ? LOCKDOWN_ROLES.has(role) : PRIVILEGED_ROLES.has(role); 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 serverTime = useMemo(() => {
const now = new Date(nowMs);
try { try {
return new Intl.DateTimeFormat('en-US', { return new Intl.DateTimeFormat('en-US', {
timeZone: timezone, timeZone: timezone,
@@ -44,15 +50,10 @@ export default function ModeGateOverlay() {
minute: '2-digit', minute: '2-digit',
second: '2-digit', second: '2-digit',
}).format(now); }).format(now);
} catch (err) { } catch {
return now.toLocaleTimeString(); return now.toLocaleTimeString();
} }
}, [now, timezone]); }, [nowMs, timezone]);
useEffect(() => {
const timer = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(timer);
}, []);
if (!restricted || privileged) { if (!restricted || privileged) {
return null; return null;
@@ -73,11 +74,6 @@ export default function ModeGateOverlay() {
{reason ? reason : 'No reason set.'} {reason ? reason : 'No reason set.'}
</p> </p>
<p className="text-center text-sm text-slate-300">Server time: {serverTime}</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>
<div className="surface-muted"> <div className="surface-muted">
<AuthPanel /> <AuthPanel />
@@ -3,6 +3,7 @@
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit. // Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx'; import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
import { useSharedClock } from '../../hooks/useSharedClock.js';
import { useSettingsNamespace } from '../../settings/index.js'; import { useSettingsNamespace } from '../../settings/index.js';
import CardFrame from '../CardFrame/index.jsx'; import CardFrame from '../CardFrame/index.jsx';
import RoverLabel from '../RoverLabel/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 [title, setTitle] = useState('');
const [titleDirty, setTitleDirty] = useState(false); const [titleDirty, setTitleDirty] = useState(false);
const [includeSidebar, setIncludeSidebar] = useState(true); const [includeSidebar, setIncludeSidebar] = useState(true);
const [remainingMs, setRemainingMs] = useState(0);
const [activeJobId, setActiveJobId] = useState(null); const [activeJobId, setActiveJobId] = useState(null);
const [dismissedPanelReplayId, setDismissedPanelReplayId] = useState(null); const [dismissedPanelReplayId, setDismissedPanelReplayId] = useState(null);
// Settings keys include the panel id because the same replay source control is // 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]); }, [sources]);
useEffect(() => { const hasReplayCooldown = Boolean(replayState?.lastTriggeredAt && replayState?.cooldownMs);
if (!replayState?.lastTriggeredAt || !replayState?.cooldownMs) { const replayCooldownEndsAt = hasReplayCooldown
setRemainingMs(0); ? replayState.lastTriggeredAt + replayState.cooldownMs
return undefined; : 0;
} const cooldownNow = useSharedClock(1000, hasReplayCooldown);
const update = () => { const remainingMs = useMemo(() => {
const next = replayState.lastTriggeredAt + replayState.cooldownMs - Date.now(); if (!hasReplayCooldown) return 0;
setRemainingMs(Math.max(0, next)); /*
}; The button only shows whole seconds, so a shared one-second clock gives the
update(); same useful information without each mounted replay panel owning a 250ms
const interval = setInterval(update, 250); interval. The exact server cooldown still decides whether the action is
return () => clearInterval(interval); accepted; this value is only the local disabled-state/display estimate.
}, [replayState?.lastTriggeredAt, replayState?.cooldownMs, replayState?.remainingMs]); */
const next = replayCooldownEndsAt - cooldownNow;
return Math.max(0, next);
}, [cooldownNow, hasReplayCooldown, replayCooldownEndsAt]);
const replayDisabled = busy || mode === 'lockdown' || remainingMs > 0 || !selected.length; const replayDisabled = busy || mode === 'lockdown' || remainingMs > 0 || !selected.length;
const selectedSet = useMemo(() => { const selectedSet = useMemo(() => {
@@ -1,8 +1,9 @@
// Rover Queues Panel // Rover Queues Panel
// Purpose: Defines the Rover Queues Panel module and the local helpers/components used in this file. // 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. // 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 { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
import { useSharedClock } from '../../hooks/useSharedClock.js';
import CardFrame from '../CardFrame/index.jsx'; import CardFrame from '../CardFrame/index.jsx';
import RoverLabel from '../RoverLabel/index.jsx'; import RoverLabel from '../RoverLabel/index.jsx';
@@ -61,7 +62,6 @@ export default function RoverQueuesPanel({ title = 'Rovers' }) {
const { requestControl, rebootOwnRover } = useSessionActions(); const { requestControl, rebootOwnRover } = useSessionActions();
const [pending, setPending] = useState({}); const [pending, setPending] = useState({});
const [rebootPending, setRebootPending] = useState(false); const [rebootPending, setRebootPending] = useState(false);
const [now, setNow] = useState(() => Date.now());
const canRequest = useMemo(() => role && role !== 'spectator', [role]); const canRequest = useMemo(() => role && role !== 'spectator', [role]);
const adminCapable = useMemo( const adminCapable = useMemo(
@@ -72,14 +72,12 @@ export default function RoverQueuesPanel({ title = 'Rovers' }) {
() => Object.values(turnQueues || {}).some((info) => info?.deadline || info?.idleDeadline), () => Object.values(turnQueues || {}).some((info) => info?.deadline || info?.idleDeadline),
[turnQueues], [turnQueues],
); );
/*
useEffect(() => { Queue timers are shown in whole seconds, and several queue panels can be
if (!hasDeadlines) return undefined; mounted across desktop/mobile/spectator layouts. Sharing the one-second
const timer = setInterval(() => { clock keeps those labels in sync while using a single interval globally.
setNow(Date.now()); */
}, 1000); const now = useSharedClock(1000, hasDeadlines);
return () => clearInterval(timer);
}, [hasDeadlines]);
const rosterItems = useMemo(() => { const rosterItems = useMemo(() => {
const known = new Set(roster.map((rover) => String(rover.id))); const known = new Set(roster.map((rover) => String(rover.id)));
+10 -10
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react'; import { useMemo } from 'react';
import { useSessionSelector } from '../context/SessionContext.jsx'; import { useSessionSelector } from '../context/SessionContext.jsx';
import { useSharedClock } from './useSharedClock.js';
export function useDriverVideoModePolicy(roverId) { export function useDriverVideoModePolicy(roverId) {
const mode = useSessionSelector((state) => state.session?.mode || null); const mode = useSessionSelector((state) => state.session?.mode || null);
@@ -8,7 +9,13 @@ export function useDriverVideoModePolicy(roverId) {
const turnQueues = useSessionSelector((state) => state.session?.turnQueues ?? {}); const turnQueues = useSessionSelector((state) => state.session?.turnQueues ?? {});
const socketId = useSessionSelector((state) => state.session?.socketId || null); const socketId = useSessionSelector((state) => state.session?.socketId || null);
const activeDrivers = useSessionSelector((state) => state.session?.activeDrivers ?? {}); 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 turnInfo = roverId ? turnQueues?.[roverId] || null : null;
const activeDriverId = roverId ? activeDrivers?.[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); const idx = queue.findIndex((id) => id === turnInfo.current);
if (idx === -1) return queue[0] || null; if (idx === -1) return queue[0] || null;
return queue[(idx + 1) % queue.length] || null; return queue[(idx + 1) % queue.length] || null;
}, [turnInfo?.queue, turnInfo?.current]); }, [turnInfo]);
const isNextDriver = Boolean(socketId && nextDriverId === socketId); const isNextDriver = Boolean(socketId && nextDriverId === socketId);
const deadline = turnInfo?.deadline || null; const deadline = turnInfo?.deadline || null;
const msUntilTurn = deadline ? deadline - now : null; const msUntilTurn = deadline ? deadline - now : null;
const isTurnsMode = mode === 'turns';
const totalRovers = roster.length; const totalRovers = roster.length;
const totalDrivers = useMemo(() => { const totalDrivers = useMemo(() => {
const unique = new Set(); const unique = new Set();
@@ -43,11 +49,5 @@ export function useDriverVideoModePolicy(roverId) {
const showNotTurnNotice = isTurnsMode && !isActiveDriver; const showNotTurnNotice = isTurnsMode && !isActiveDriver;
const forceSnapshotByTurnPolicy = showNotTurnNotice && !isPreSwitchWindow && shouldUsePreviewByLoad; 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; return forceSnapshotByTurnPolicy ? 'snapshot' : null;
} }
+120
View File
@@ -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;
}