Add event-driven state sync with ETag optimization
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user