diff --git a/app.py b/app.py index 959957f..a9a3b61 100644 --- a/app.py +++ b/app.py @@ -30,7 +30,7 @@ from ui_builder import install_ui_builder from khl_site.khl_data_center import APP as khl_site_app BASE_DIR = Path(__file__).resolve().parent -BUILD_VERSION = "2026.09.18.9" +BUILD_VERSION = "2026.09.18.10" # compatibility: BUILD_VERSION = "2026.08.26.1" # compatibility: BUILD_VERSION = "2026.08.25.1" # compatibility: BUILD_VERSION = "2026.08.24.15" diff --git a/hockey_data/static/tournament-menu.js b/hockey_data/static/tournament-menu.js index 36879c2..f2d12a1 100644 --- a/hockey_data/static/tournament-menu.js +++ b/hockey_data/static/tournament-menu.js @@ -63,6 +63,8 @@ lastGameOpenError: "", drawerRefreshTimer: null, drawerRefreshBusy: false, + operatorSettingsPaused: false, + settingsPauseObserver: null, }; const text = { @@ -1062,6 +1064,7 @@ function scheduleDrawerRefresh(delay = 60000) { stopDrawerRefresh(); + if (operatorSettingsEditingActive()) return; if (!state.open && !state.selectedId) return; state.drawerRefreshTimer = setTimeout(async () => { await refreshDrawerCache(); @@ -1700,6 +1703,9 @@ function gameCard(game) { async function runScheduleOnlineCycle({ mode = "live" } = {}) { if (!state.selectedId || state.scheduleOnlineBusy) return null; if (!scheduleOnlineContextMatches()) return null; + // BUILD152: absolutely no Stat2TV data polling while any operator settings + // window is open. ONLINE stays logically enabled and resumes after close. + if (operatorSettingsEditingActive()) return null; state.scheduleOnlineBusy = true; try { const result = await request(scheduleOnlineEndpoint({ @@ -1708,6 +1714,10 @@ function gameCard(game) { language: state.scheduleOnlineContext.language, }), { method: "POST", timeoutMs: 45000 }); if (!scheduleOnlineContextMatches()) return result; + // A settings window may have opened while the HTTP request was in flight. + // Do not rebuild Runtime under the operator in that case; resume will + // perform a fresh discover pass after settings are closed. + if (operatorSettingsEditingActive()) return result; state.scheduleLive = result?.schedule || state.scheduleLive; applyGamesData(); return result; @@ -1718,6 +1728,7 @@ function gameCard(game) { async function scheduleOnlineTimerTick() { if (!state.scheduleOnlineActive) return; + if (operatorSettingsEditingActive()) return; if (!scheduleOnlineContextMatches()) { await stopScheduleOnline({ silent: true }); return; @@ -1750,9 +1761,11 @@ function gameCard(game) { try { const result = await runScheduleOnlineCycle({ mode: "seed" }); if (!state.scheduleOnlineActive) return result; - state.scheduleOnlineTimer = window.setInterval(() => { - void scheduleOnlineTimerTick(); - }, 1000); + if (!operatorSettingsEditingActive()) { + state.scheduleOnlineTimer = window.setInterval(() => { + void scheduleOnlineTimerTick(); + }, 1000); + } notify(`${t("onlineUpdated")} ยท live: ${Number(result?.live || 0)}`); window.dispatchEvent(new CustomEvent("hockey:schedule-online-state", { detail: { active: true } })); return result; @@ -1764,10 +1777,7 @@ function gameCard(game) { } async function stopScheduleOnline({ silent = false, keepBase = false } = {}) { - if (state.scheduleOnlineTimer) { - window.clearInterval(state.scheduleOnlineTimer); - state.scheduleOnlineTimer = null; - } + stopScheduleOnlineTimerOnly(); const wasActive = state.scheduleOnlineActive; state.scheduleOnlineActive = false; state.scheduleOnlineContext = null; @@ -2206,18 +2216,90 @@ function selectedGameIsFinished() { } function operatorSettingsEditingActive() { - // BUILD144: settings are operator-owned UI state. Live match/schedule refresh - // may continue on the server, but must not rebuild the Runtime while an - // operator is typing into either Stat2TV settings or a full UI settings modal. + // BUILD152: settings are a hard pause for all browser-side hockey data + // refresh loops. The server's global maintenance jobs are intentionally not + // controlled by one operator browser session. return Boolean( - state.settingsModal + state.operatorSettingsPaused + || state.settingsModal || document.querySelector(".hockey-settings-modal") || document.querySelector(".modal-settings-full") ); } +function stopScheduleOnlineTimerOnly() { + if (state.scheduleOnlineTimer) { + window.clearInterval(state.scheduleOnlineTimer); + state.scheduleOnlineTimer = null; + } +} + +function startScheduleOnlineTimerOnly() { + stopScheduleOnlineTimerOnly(); + if (!state.scheduleOnlineActive || !state.scheduleOnlineContext) return; + if (operatorSettingsEditingActive()) return; + state.scheduleOnlineTimer = window.setInterval(() => { + void scheduleOnlineTimerTick(); + }, 1000); +} + +function pauseOperatorDataRefreshes() { + if (state.operatorSettingsPaused) return; + state.operatorSettingsPaused = true; + stopDrawerRefresh(); + stopSelectedGamePolling(); + stopScheduleOnlineTimerOnly(); + window.dispatchEvent(new CustomEvent("hockey:data-refresh-paused", { detail: { paused: true } })); +} + +function resumeOperatorDataRefreshes() { + if (!state.operatorSettingsPaused) return; + state.operatorSettingsPaused = false; + + // ONLINE remains logically active while settings are open. Re-discover once + // after close, then return to one-second polling of live rows. + if (state.scheduleOnlineActive && state.scheduleOnlineContext) { + void (async () => { + try { + await runScheduleOnlineCycle({ mode: "discover" }); + } catch (error) { + console.warn("[Hockey] ONLINE resume refresh failed:", error); + } finally { + startScheduleOnlineTimerOnly(); + } + })(); + } + + if (state.selectedGameId && !selectedGameIsFinished()) { + scheduleSelectedGamePoll(150); + } + if (state.selectedId) scheduleDrawerRefresh(250); + window.dispatchEvent(new CustomEvent("hockey:data-refresh-paused", { detail: { paused: false } })); +} + +function syncOperatorSettingsPauseState() { + const active = Boolean( + state.settingsModal + || document.querySelector(".hockey-settings-modal") + || document.querySelector(".modal-settings-full") + ); + if (active) pauseOperatorDataRefreshes(); + else resumeOperatorDataRefreshes(); +} + +function installSettingsPauseObserver() { + if (state.settingsPauseObserver || !document.body) return; + state.settingsPauseObserver = new MutationObserver(() => { + syncOperatorSettingsPauseState(); + }); + state.settingsPauseObserver.observe(document.body, { childList: true, subtree: true }); + window.addEventListener("hockey:settings-visibility", () => syncOperatorSettingsPauseState()); + syncOperatorSettingsPauseState(); +} + function scheduleSelectedGamePoll(delay = 1000) { stopSelectedGamePolling(); + if (operatorSettingsEditingActive()) return; if (!state.selectedGameId || selectedGameIsFinished()) { renderSelectedChip(); return; @@ -2711,6 +2793,7 @@ document.addEventListener("visibilitychange", () => { `; document.body.appendChild(modal); state.settingsModal = modal; + syncOperatorSettingsPauseState(); const form = modal.querySelector("form"); form.elements.auth_mode.value = value.auth_mode || "auto"; @@ -2720,10 +2803,7 @@ document.addEventListener("visibilitychange", () => { const close = () => { modal.remove(); state.settingsModal = null; - if (state.selectedGameId && !selectedGameIsFinished()) { - scheduleSelectedGamePoll(150); - } - if (state.selectedId) scheduleDrawerRefresh(250); + syncOperatorSettingsPauseState(); }; modal.querySelector("[data-settings-close]").addEventListener("click", close); modal.querySelector(".hockey-settings-backdrop").addEventListener("click", close); @@ -2818,8 +2898,10 @@ document.addEventListener("visibilitychange", () => { } status.classList.remove("error"); } - setTimeout(close, 120); - loadNavigation().catch((error) => console.warn("[Hockey] Navigation refresh after settings save failed:", error)); + setTimeout(() => { + close(); + loadNavigation().catch((error) => console.warn("[Hockey] Navigation refresh after settings save failed:", error)); + }, 120); } catch (error) { status.textContent = error.message; status.classList.add("error"); @@ -2830,6 +2912,7 @@ document.addEventListener("visibilitychange", () => { async function init() { await ensureAccountScopedRuntimeState(); ensureShell(); + installSettingsPauseObserver(); renderStaticLabels(); // Warm the tiny local settings endpoint while navigation is loading, so the // gear opens immediately instead of starting its first request on click. diff --git a/ui_builder/static/app.js b/ui_builder/static/app.js index a539f93..89a4064 100644 --- a/ui_builder/static/app.js +++ b/ui_builder/static/app.js @@ -1,3 +1,4 @@ +// BUILD152: all browser-side hockey data polling pauses while operator settings are open. // BUILD151: Schedule ONLINE is a toggle; one-second live polling uses isolated schedule_live. // BUILD150: Schedule ONLINE button refreshes score/status from language-specific per-game JSON. // BUILD146: schedule tab remains permanent; score/status refresh is driven by tournament-menu.js. @@ -4582,7 +4583,15 @@ function startCustomTooltips() { refreshQuickPanelOnAirClasses(); } + function hockeySettingsDataRefreshPaused() { + return Boolean( + document.querySelector(".modal-settings-full") + || document.querySelector(".hockey-settings-modal") + ); + } + async function pollQuickPanelOverlayState({ force = false } = {}) { + if (hockeySettingsDataRefreshPaused()) return false; if (state.quickPanelOverlayPollPending && !force) return false; const deviceId = currentRuntimeVmixDeviceId(); if (!deviceId) return false; @@ -4602,8 +4611,16 @@ function startCustomTooltips() { } } + function stopQuickPanelOverlayPolling() { + if (state.quickPanelOverlayPollTimer) { + window.clearInterval(state.quickPanelOverlayPollTimer); + state.quickPanelOverlayPollTimer = null; + } + } + function startQuickPanelOverlayPolling() { - if (state.quickPanelOverlayPollTimer) window.clearInterval(state.quickPanelOverlayPollTimer); + stopQuickPanelOverlayPolling(); + if (hockeySettingsDataRefreshPaused()) return; pollQuickPanelOverlayState({ force: true }).catch(() => {}); state.quickPanelOverlayPollTimer = window.setInterval(() => { pollQuickPanelOverlayState().catch(() => {}); @@ -15255,15 +15272,20 @@ function renderHockeyPenaltyDashboard(node, component, runtime) { function showSettingsModal(title, bodyHtml, options = {}) { const className = ["modal-settings-full", String(options.className || "").trim()].filter(Boolean).join(" "); showModal(title, bodyHtml, { ...options, locked: true, className }); + window.dispatchEvent(new CustomEvent("hockey:settings-visibility", { detail: { open: true } })); } function closeModal() { + const closingSettings = Boolean(el.modalHost?.querySelector?.(".modal-settings-full")); closeControlPopover(); if (state.shortcutCapture) { state.shortcutCapture = null; state.pressedShortcutModifiers.clear(); } clearInterval(state.timerQuickEditorInterval); state.timerQuickEditorInterval = null; state.modalLocked = false; el.modalHost.innerHTML = ""; + if (closingSettings) { + window.dispatchEvent(new CustomEvent("hockey:settings-visibility", { detail: { open: false } })); + } renderHockeyRuntimeSideWindows(); scheduleRuntimeScale(); } @@ -15386,11 +15408,25 @@ function renderHockeyPenaltyDashboard(node, component, runtime) { } scheduleRuntimeScale(); }); - window.addEventListener("hockey:settings-updated", () => { + const refreshHockeyRuntimeAfterSettings = () => { const gameId = hockeyTimerSelectedGameId(); - if (gameId) hockeyLoadGameControl(gameId, { force: true, rerender: false }).then(() => { + if (!gameId) return; + hockeyLoadGameControl(gameId, { force: true, rerender: false }).then(() => { if (hockeyTeamStateScoreboardIsLive()) hockeySyncTeamStateOverlays({ force: true }).catch(() => {}); }); + }; + window.addEventListener("hockey:data-refresh-paused", (event) => { + const paused = Boolean(event?.detail?.paused); + if (paused) { + stopQuickPanelOverlayPolling(); + } else if (boot.mode === "runtime") { + startQuickPanelOverlayPolling(); + refreshHockeyRuntimeAfterSettings(); + } + }); + window.addEventListener("hockey:settings-updated", () => { + if (hockeySettingsDataRefreshPaused()) return; + refreshHockeyRuntimeAfterSettings(); }); window.addEventListener("hockey:game-selected", (event) => { const gameId = String(event?.detail?.game?.external_id || event?.detail?.game?.id || "").trim();