Add event-driven state sync with ETag optimization

This commit is contained in:
2026-02-18 19:58:57 +01:00
parent 5b921063ec
commit 3c7f3d2114
17 changed files with 493 additions and 30 deletions

View File

@@ -22,6 +22,7 @@ import {
updatePhaseNav,
configureUiRuntime,
} from "./js/ui.js";
import { api } from "./js/api.js";
import { loadSuggestData, loadVoteData, refreshPhaseData } from "./js/data.js";
import { setupAuthHandlers } from "./js/app-auth-handlers.js";
import { setupAdminHandlers } from "./js/app-admin-handlers.js";
@@ -29,11 +30,16 @@ import { setupVoteNavigationHandlers } from "./js/app-vote-nav-handlers.js";
const REFRESH_MIN_MS = 3000;
const REFRESH_MAX_MS = 20000;
const EVENTS_RECONNECT_MIN_MS = 1000;
const EVENTS_RECONNECT_MAX_MS = 15000;
let refreshInFlight = null;
let refreshTimerId = null;
let refreshSchedulerStarted = false;
let unchangedRefreshCycles = 0;
let nextRefreshDelayMs = REFRESH_MIN_MS;
let stateEventSource = null;
let eventsReconnectTimerId = null;
let eventsReconnectDelayMs = EVENTS_RECONNECT_MIN_MS;
async function runSerializedRefresh() {
if (refreshInFlight) return refreshInFlight;
@@ -47,13 +53,81 @@ async function refreshWithUiErrorHandling() {
try {
const changed = await runSerializedRefresh();
updateRefreshCadence(changed === true);
if (state.isAuthenticated) {
ensureStateEventStream();
} else {
closeStateEventStream();
}
} catch (err) {
// Back off after transient failures to avoid hammering server/dependencies.
nextRefreshDelayMs = Math.min(nextRefreshDelayMs * 2, REFRESH_MAX_MS);
if (!handleAuthError(err, clearUserState)) toast(err.message, true);
if (handleAuthError(err, clearUserState)) {
closeStateEventStream();
return;
}
toast(err.message, true);
}
}
function closeStateEventStream() {
if (eventsReconnectTimerId !== null) {
window.clearTimeout(eventsReconnectTimerId);
eventsReconnectTimerId = null;
}
if (stateEventSource) {
stateEventSource.close();
stateEventSource = null;
}
}
function scheduleStateEventReconnect() {
if (eventsReconnectTimerId !== null || !state.isAuthenticated) return;
eventsReconnectTimerId = window.setTimeout(() => {
eventsReconnectTimerId = null;
ensureStateEventStream();
}, eventsReconnectDelayMs);
eventsReconnectDelayMs = Math.min(
Math.round(eventsReconnectDelayMs * 1.8),
EVENTS_RECONNECT_MAX_MS,
);
}
function ensureStateEventStream() {
if (!state.isAuthenticated || typeof window.EventSource === "undefined") {
closeStateEventStream();
return;
}
if (stateEventSource) return;
stateEventSource = new EventSource(api.stateEventsUrl(), {
withCredentials: true,
});
stateEventSource.onopen = () => {
eventsReconnectDelayMs = EVENTS_RECONNECT_MIN_MS;
};
stateEventSource.onerror = () => {
if (!stateEventSource) return;
stateEventSource.close();
stateEventSource = null;
scheduleStateEventReconnect();
};
stateEventSource.addEventListener("state", () => {
unchangedRefreshCycles = 0;
nextRefreshDelayMs = baseRefreshDelayForPhase();
if (!document.hidden && !state.adminStatusSelectActive) {
refreshWithUiErrorHandling();
}
});
}
function scheduleNextRefresh() {
refreshTimerId = window.setTimeout(async () => {
if (!document.hidden && !state.adminStatusSelectActive) {
@@ -119,6 +193,9 @@ function setupHandlers() {
setupAdminHandlers({ runSerializedRefresh });
setupVoteNavigationHandlers({ runSerializedRefresh });
setupLanguageSwitchers();
document.getElementById("logout")?.addEventListener("click", () => {
closeStateEventStream();
});
onLanguageChange(() => {
updateLanguageButtons();