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();

View File

@@ -27,6 +27,10 @@ Jeder Spieler durchläuft die Phasen unabhängig voneinander:
Klicke auf **„Weiter"**, um fortzufahren. Admins können sich bei Bedarf auch wieder zurücksetzen.
In der **Vorschlagsphase** bleibt **„Weiter"** deaktiviert, bis dein Konto mindestens einen eigenen Spielvorschlag hat.
### Muss ich die Seite manuell aktualisieren?
Normalerweise nicht. Pick'n'Play erhält Live-Updates vom Server und nutzt nur dann periodische Prüfungen, wenn der Live-Kanal vorübergehend nicht verfügbar ist.
## Spiele vorschlagen
### Wie viele Spiele kann ich vorschlagen?

View File

@@ -28,6 +28,10 @@ Each player progresses independently through the phases:
Click **"Next"** to move forward. Admins can move themselves backward if needed.
In the **Suggest** phase, **Next** stays disabled until your account has at least one own game suggestion.
### Do I need to refresh the page manually?
Usually no. Pick'n'Play receives live server updates and falls back to periodic checks if the live channel is temporarily unavailable.
## Suggesting Games
### How many games can I suggest?

View File

@@ -18,24 +18,53 @@ async function request(path, { method = "GET", body } = {}) {
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
let msg = `${res.status}`;
try {
const data = await res.json();
msg =
data.error || data.detail || data.title || JSON.stringify(data);
} catch {
/* ignore */
}
const err = new Error(msg);
err.status = res.status;
throw err;
}
if (!res.ok) throw await toApiError(res);
return res.status === 204 ? null : res.json();
}
async function requestState(ifNoneMatch) {
const headers = { ...defaultHeaders };
if (ifNoneMatch) headers["If-None-Match"] = ifNoneMatch;
const res = await fetch(withBase("/api/state"), {
method: "GET",
credentials: "same-origin",
headers,
});
if (res.status === 304) {
return {
notModified: true,
etag: res.headers.get("ETag"),
data: null,
};
}
if (!res.ok) throw await toApiError(res);
return {
notModified: false,
etag: res.headers.get("ETag"),
data: await res.json(),
};
}
async function toApiError(res) {
let msg = `${res.status}`;
try {
const data = await res.json();
msg = data.error || data.detail || data.title || JSON.stringify(data);
} catch {
/* ignore */
}
const err = new Error(msg);
err.status = res.status;
return err;
}
export const api = {
state: () => request("/api/state"),
state: (ifNoneMatch) => requestState(ifNoneMatch),
stateEventsUrl: () => withBase("/api/events/state"),
me: () => request("/api/me"),
authOptions: () => request("/api/auth/options"),
register: (payload) =>

View File

@@ -18,14 +18,27 @@ import {
import { state, clearUserState } from "./state.js";
export async function loadState() {
const [me, stateData] = await Promise.all([api.me(), api.state()]);
const stateResponse = await api.state(state.stateEtag);
if (stateResponse?.etag) state.stateEtag = stateResponse.etag;
if (stateResponse?.notModified) return false;
const stateData = stateResponse.data;
state.isAuthenticated = true;
state.me = me;
state.hasJoker = me.hasJoker ?? false;
state.me = {
id: stateData.id,
username: stateData.username,
displayName: stateData.displayName,
isAdmin: stateData.isAdmin,
isOwner: stateData.isOwner,
currentPhase: stateData.currentPhase,
votesFinal: stateData.votesFinal,
hasJoker: stateData.hasJoker,
};
state.hasJoker = stateData.hasJoker ?? false;
state.prevPhase = state.phase;
state.phase = stateData.currentPhase;
state.resultsOpen = stateData.resultsOpen;
state.votesFinal = stateData.votesFinal ?? me?.votesFinal ?? false;
state.votesFinal = stateData.votesFinal ?? false;
state.counts = stateData;
if (state.prevPhase !== state.phase && state.phase === "Vote") {
state.votesRendered = false;
@@ -34,6 +47,7 @@ export async function loadState() {
renderWelcome();
renderPhasePill();
renderCounts();
return true;
}
export async function loadSuggestData() {
@@ -105,7 +119,19 @@ export async function refreshPhaseData() {
try {
const prevPhase = state.phase;
const prevResultsOpen = state.resultsOpen;
await loadState();
const stateChanged = await loadState();
const adminCard = document.getElementById("admin-card");
const adminPanelVisible =
!!adminCard && !adminCard.classList.contains("hidden");
if (!stateChanged) {
if (state.me?.isAdmin && adminPanelVisible) {
state.adminVoteStatus = await adminApi.voteStatus();
}
updatePhaseNav();
return false;
}
await Promise.all([
loadSuggestData(),
loadSuggestionsData(),
@@ -117,9 +143,6 @@ export async function refreshPhaseData() {
state.votesRendered = false;
await loadVoteData();
}
const adminCard = document.getElementById("admin-card");
const adminPanelVisible =
!!adminCard && !adminCard.classList.contains("hidden");
if (state.me?.isAdmin && adminPanelVisible) {
state.adminVoteStatus = await adminApi.voteStatus();
}

View File

@@ -17,6 +17,7 @@ export const state = {
votesRendered: false,
adminVoteStatus: null,
adminStatusSelectActive: false,
stateEtag: null,
};
export function clearUserState() {
@@ -34,7 +35,9 @@ export function clearUserState() {
state.myVotes = [];
state.results = [];
state.votesRendered = false;
state.adminVoteStatus = null;
state.adminStatusSelectActive = false;
state.stateEtag = null;
const adminCard = document.getElementById("admin-card");
if (adminCard) adminCard.classList.add("hidden");
}