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
+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]);
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);
+11 -15
View File
@@ -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)));