[#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:
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user