Add stale data banner when game state hasn't updated in 20s

Subtle top banner with dimmed page (not a blocking overlay like the
full disconnect state). Shows elapsed time since last update.
Separate from the hard disconnect which triggers after 3 fetch failures.

Debug panel: added 'Stale' button to test.
This commit is contained in:
Jarvis 2026-02-21 11:13:19 +00:00
parent 34a4f47402
commit dd4787615d

View file

@ -140,6 +140,19 @@ h1 { color: #fff; font-size: 1.5em; text-align: center; }
.disconnected-overlay .retry { color: #555; font-size: 0.8em; margin-top: 15px; } .disconnected-overlay .retry { color: #555; font-size: 0.8em; margin-top: 15px; }
body.greyed-out .container { filter: grayscale(80%) brightness(0.5); pointer-events: none; } body.greyed-out .container { filter: grayscale(80%) brightness(0.5); pointer-events: none; }
/* ===== STALE STATE ===== */
.stale-banner {
position: fixed; top: 0; left: 0; right: 0; z-index: 400;
background: rgba(30, 30, 50, 0.92); border-bottom: 1px solid #444;
padding: 8px 20px; text-align: center; font-size: 0.85em; color: #aaa;
display: none; backdrop-filter: blur(2px);
animation: stale-fade-in 0.3s ease-out;
}
@keyframes stale-fade-in { from { opacity: 0; transform: translateY(-100%); } to { opacity: 1; transform: translateY(0); } }
.stale-banner .icon { margin-right: 6px; }
.stale-banner .age { color: #f39c12; font-weight: 500; }
body.stale .container { filter: brightness(0.8) saturate(0.7); }
/* ===== GAME OVER ===== */ /* ===== GAME OVER ===== */
.game-over-banner { .game-over-banner {
position: fixed; top: 0; left: 0; right: 0; position: fixed; top: 0; left: 0; right: 0;
@ -223,6 +236,12 @@ body.game-over .container { padding-top: 100px; }
</div> </div>
</div> </div>
<!-- Stale data banner -->
<div class="stale-banner" id="stale-banner">
<span class="icon">⏸️</span>
Game data hasn't updated in <span class="age" id="stale-age">--</span> — the bridge may have stopped
</div>
<!-- Confetti canvas --> <!-- Confetti canvas -->
<canvas id="confetti-canvas"></canvas> <canvas id="confetti-canvas"></canvas>
@ -238,6 +257,7 @@ body.game-over .container { padding-top: 100px; }
<button onclick="debugState('zero')">Zero State (no game)</button> <button onclick="debugState('zero')">Zero State (no game)</button>
<button onclick="debugState('playing')">Playing (demo)</button> <button onclick="debugState('playing')">Playing (demo)</button>
<button onclick="debugState('gameover')">Game Over (confetti)</button> <button onclick="debugState('gameover')">Game Over (confetti)</button>
<button onclick="debugState('stale')">Stale (no updates)</button>
<button onclick="debugState('disconnected')">Disconnected</button> <button onclick="debugState('disconnected')">Disconnected</button>
<button onclick="debugState('reset')">Reset (live mode)</button> <button onclick="debugState('reset')">Reset (live mode)</button>
</div> </div>
@ -267,6 +287,8 @@ let retryCountdown = 0;
let retryTimer = null; let retryTimer = null;
let gameOverShown = false; // only trigger confetti once per game let gameOverShown = false; // only trigger confetti once per game
let confettiRunning = false; let confettiRunning = false;
let lastStateTimestamp = null; // ISO string from state.lastUpdated
const STALE_THRESHOLD_MS = 20000;
function showView(view) { function showView(view) {
currentView = view; currentView = view;
@ -553,6 +575,37 @@ function isGameOver(state) {
return false; return false;
} }
// ===== STALE DETECTION =====
function checkStale() {
if (!lastStateTimestamp || currentView === 'zero' || currentView === 'gameover') {
hideStale();
return;
}
const age = Date.now() - new Date(lastStateTimestamp).getTime();
if (age > STALE_THRESHOLD_MS) {
showStale(age);
} else {
hideStale();
}
}
function showStale(ageMs) {
const secs = Math.floor(ageMs / 1000);
let ageText;
if (secs < 60) ageText = `${secs}s`;
else if (secs < 3600) ageText = `${Math.floor(secs / 60)}m ${secs % 60}s`;
else ageText = `${Math.floor(secs / 3600)}h ${Math.floor((secs % 3600) / 60)}m`;
document.getElementById('stale-age').textContent = ageText;
document.getElementById('stale-banner').style.display = 'block';
document.body.classList.add('stale');
}
function hideStale() {
document.getElementById('stale-banner').style.display = 'none';
document.body.classList.remove('stale');
}
// ===== CONNECTION MONITORING ===== // ===== CONNECTION MONITORING =====
function showDisconnected() { function showDisconnected() {
if (currentView === 'disconnected') return; if (currentView === 'disconnected') return;
@ -592,6 +645,10 @@ async function update() {
if (consecutiveFailures >= 3) hideDisconnected(); if (consecutiveFailures >= 3) hideDisconnected();
consecutiveFailures = 0; consecutiveFailures = 0;
// Track last update time for stale detection
if (state.lastUpdated) lastStateTimestamp = state.lastUpdated;
checkStale();
// Determine what to show // Determine what to show
if (!state.players || state.players.length === 0) { if (!state.players || state.players.length === 0) {
// No players = no game // No players = no game
@ -633,6 +690,7 @@ async function update() {
} catch (e) { } catch (e) {
consecutiveFailures++; consecutiveFailures++;
checkStale(); // still update stale timer on failures
if (consecutiveFailures >= 3) { if (consecutiveFailures >= 3) {
showDisconnected(); showDisconnected();
} }
@ -719,7 +777,9 @@ function debugState(mode) {
if (mode === 'reset') { if (mode === 'reset') {
debugMode = null; debugMode = null;
lastUpdate = ''; lastUpdate = '';
lastStateTimestamp = null;
hideDisconnected(); hideDisconnected();
hideStale();
update(); update();
return; return;
} }
@ -741,6 +801,14 @@ function debugState(mode) {
document.getElementById('winner-name').textContent = `🏆 ${winner} WINS!`; document.getElementById('winner-name').textContent = `🏆 ${winner} WINS!`;
startConfetti(); startConfetti();
document.getElementById('status').textContent = 'Debug: Game Over'; document.getElementById('status').textContent = 'Debug: Game Over';
} else if (mode === 'stale') {
const st = demoPlayingState();
st.lastUpdated = new Date(Date.now() - 120000).toISOString(); // 2 min ago
lastStateTimestamp = st.lastUpdated;
showView('playing');
renderGame(st);
checkStale();
document.getElementById('status').textContent = 'Debug: Stale data';
} else if (mode === 'disconnected') { } else if (mode === 'disconnected') {
// Show game underneath the overlay // Show game underneath the overlay
const st = demoPlayingState(); const st = demoPlayingState();