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
+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 { 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;
}
+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;
}