[#11] WebSocket reconnection with exponential backoff + offline vote queue

- Exponential backoff: 1s → 2s → 4s → … → 30s max
- Full-screen overlay when disconnected (spinner + message)
- Votes cast offline are queued and replayed on reconnect
- Deduplicated queue: same voter+option only queued once
- ✓ Reconnected toast on successful reconnect
This commit is contained in:
2026-04-28 21:43:58 -07:00
parent a6e07258c6
commit bc18555432

View File

@@ -628,6 +628,30 @@
/* Touch highlight */ /* Touch highlight */
.option-card { -webkit-tap-highlight-color: rgba(0,212,255,0.12); } .option-card { -webkit-tap-highlight-color: rgba(0,212,255,0.12); }
/* ── Connection overlay ─────────────────────────────────── */
#wsOverlay {
display: none;
position: fixed;
inset: 0;
background: rgba(11,13,20,0.92);
z-index: 900;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 14px;
}
#wsOverlay.show { display: flex; }
#wsOverlay .spinner {
width: 40px; height: 40px;
border: 3px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
#wsOverlay p { color: var(--text-muted); font-size: 0.85rem; text-align: center; }
#wsOverlay .sub { font-size: 0.72rem; color: var(--text-muted); opacity: 0.6; margin-top: -8px; }
</style> </style>
</head> </head>
<body> <body>
@@ -691,6 +715,13 @@
</div> </div>
</main> </main>
<!-- Connection lost overlay -->
<div id="wsOverlay">
<div class="spinner"></div>
<p>Connection lost</p>
<div class="sub">Attempting to reconnect…</div>
</div>
<!-- Toast --> <!-- Toast -->
<div class="toast" id="toast"></div> <div class="toast" id="toast"></div>
@@ -745,7 +776,14 @@
} }
// ── WebSocket ────────────────────────────────────────────── // ── WebSocket ──────────────────────────────────────────────
const RECONNECT_BASE = 1000; // 1s initial
const RECONNECT_MAX = 30000; // 30s cap
let reconnectDelay = RECONNECT_BASE;
let reconnectTimer = null;
let offlineVoteQueue = []; // votes cast while disconnected
function connectWS() { function connectWS() {
if (ws) { ws.onclose = ws.onerror = null; ws.close(); }
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${location.host}`; const wsUrl = `${protocol}//${location.host}`;
ws = new WebSocket(wsUrl); ws = new WebSocket(wsUrl);
@@ -754,6 +792,17 @@
state.wsConnected = true; state.wsConnected = true;
updateWsStatus('Connected'); updateWsStatus('Connected');
document.getElementById('wsDot').classList.remove('offline'); document.getElementById('wsDot').classList.remove('offline');
document.getElementById('wsOverlay').classList.remove('show');
reconnectDelay = RECONNECT_BASE; // reset backoff
// Replay queued votes
if (offlineVoteQueue.length > 0) {
const queue = [...offlineVoteQueue];
offlineVoteQueue = [];
queue.forEach(msg => wsSend(msg));
}
showToast('✓ Reconnected', 'success');
}; };
ws.onmessage = (event) => { ws.onmessage = (event) => {
@@ -768,10 +817,7 @@
} else if (msg.type === 'vote_update') { } else if (msg.type === 'vote_update') {
msg.results.forEach(r => { msg.results.forEach(r => {
const opt = state.options.find(o => o.id === r.id); const opt = state.options.find(o => o.id === r.id);
if (opt) { if (opt) { opt.votes = r.votes; opt.voters = r.voters; }
opt.votes = r.votes;
opt.voters = r.voters;
}
}); });
render(); render();
} else if (msg.type === 'option_added' || msg.type === 'option_approved') { } else if (msg.type === 'option_added' || msg.type === 'option_approved') {
@@ -794,12 +840,25 @@
state.wsConnected = false; state.wsConnected = false;
updateWsStatus('Reconnecting…'); updateWsStatus('Reconnecting…');
document.getElementById('wsDot').classList.add('offline'); document.getElementById('wsDot').classList.add('offline');
setTimeout(connectWS, 3000); document.getElementById('wsOverlay').classList.add('show');
clearTimeout(reconnectTimer);
reconnectTimer = setTimeout(() => {
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX);
connectWS();
}, reconnectDelay);
}; };
} }
function wsSend(obj) { function wsSend(obj) {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj)); if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(obj));
} else {
// Queue vote if offline
if (obj.type === 'vote' && !offlineVoteQueue.find(m =>
m.type === 'vote' && m.optionId === obj.optionId && m.voterName === obj.voterName)) {
offlineVoteQueue.push(obj);
}
}
} }
function updateWsStatus(text) { function updateWsStatus(text) {