(() => { "use strict"; const boot = window.UI_BUILDER_BOOT || {}; const storage = { language: "hockey.language", tournament: "hockey.selectedTournamentId", game: "hockey.selectedGameId", gameDate: "hockey.gameDate", session: "hockey.operatorSessionToken", vmixDevice: "hockey.vmix.selected_device", account: "hockey.accountId", }; const state = { open: false, loading: false, language: localStorage.getItem(storage.language) || "ru", view: "tournaments", mode: "today", search: "", league: "", season: "", stage: "", selectedId: localStorage.getItem(storage.tournament) || "", selectedGameId: localStorage.getItem(storage.game) || "", selectedTournament: null, selectedGame: null, selectedMatchDetails: null, standings: null, tournamentStatistics: null, statLabels: null, gameDate: localStorage.getItem(storage.gameDate) || localDateIso(), navigation: null, games: null, teamSchedule: null, teamScheduleTournamentId: "", settings: null, settingsPromise: null, settingsLoadedAt: 0, root: null, drawer: null, backdrop: null, selectedChip: null, settingsModal: null, gamesAutoSyncAttempted: new Set(), scheduleSyncBusy: false, gameOpenBusy: false, gameOpenGeneration: 0, gamePollTimer: null, gamePollBusy: false, gamePollErrorCount: 0, gamePollStoppedGameId: "", currentUser: null, isAdmin: false, lastAttemptedGameId: "", lastGameOpenError: "", }; const text = { ru: { title: "Хоккей", tournaments: "Турниры", matches: "Матчи", today: "Сегодня", all: "Все турниры", search: "Поиск турнира…", league: "Лига", season: "Сезон", stage: "Стадия", allValues: "Все", noToday: "На выбранную дату активных турниров нет", fallback: "Показываем последний доступный сезон", noItems: "Турниры не найдены", settings: "Настройки Stat2TV", sync: "Обновить турниры", configured: "Подключение настроено", notConfigured: "Нужно настроить подключение", selectedTournament: "Выбран турнир", selectedGame: "Выбран матч", changeTournament: "Сменить турнир", scheduleCached: "Расписание загружено · даты переключаются локально", chooseTournament: "Выбрать турнир", liveUpdate: "Автообновление 1 сек", updateStopped: "Матч завершён · обновление остановлено", close: "Закрыть", back: "Назад к турнирам", save: "Сохранить", test: "Проверить соединение", importSeed: "Импортировать приложенный XML", apiAddress: "Адрес API", username: "Логин", password: "Новый пароль", authMode: "Авторизация", displayLanguage: "Язык данных в вебе", fallbackLanguage: "Резервный язык", interval: "Интервал обновления, сек", verifySsl: "Проверять SSL", credentialsHint: "Пустой пароль оставляет прежний. Данные сохраняются только локально на сервере.", saved: "Настройки сохранены", connectionOk: "Соединение установлено", syncOk: "Турниры обновлены", sourceSeed: "Сейчас используются данные приложенного XML", sourceApi: "Последняя синхронизация из Stat2TV", offSeason: "Межсезонье", gamesForDate: "Матчи на дату", noGames: "На выбранную дату матчей нет", noGamesLoaded: "Календарь матчей ещё не загружен", updateGames: "Обновить матчи", discovering: "Ищем XML матчей…", loadingGames: "Загружаем календарь…", gamesLoaded: "Матчи загружены", openGame: "Открыть матч", gameOpened: "Матч открыт", loadingMatchTitle: "Загружаем матч", loadingMatchHint: "Пожалуйста, дождитесь окончания загрузки и ничего не нажимайте", loadingMatchDetails: "Получаем составы и статистику матча…", loadingMatchSession: "Создаём рабочую сессию…", loadingMatchStandings: "Обновляем турнирные данные…", loadingMatchFinishing: "Подготавливаем интерфейс…", endpointTemplate: "Шаблон XML матчей", endpointHint: "Оставьте пустым для автоматического поиска. Доступны переменные {tournament_id}, {date}, {season}.", endpointAuto: "Автоматически искать endpoint матчей", cacheSeconds: "Кэш матчей, сек", rediscover: "Найти endpoint заново", diagnostics: "Диагностика", connections: "Подключения", checkAgain: "Проверить ещё раз", deepMatchTest: "Проверить выбранный матч", matchSelectionReady: "Открытие матча", remoteSyncReady: "Удалённая синхронизация", database: "PostgreSQL", stat2tv: "Stat2TV", agentVmix: "Agent / vMix", selectedMatchCheck: "Выбранный матч", optionalConnection: "не требуется для открытия матча", adminOnly: "Только для администратора", startTime: "Начало", arena: "Арена", gameNumber: "Матч", score: "Счёт", unknownTeams: "Команды не распознаны", syncFailed: "Не удалось загрузить матчи", currentSession: "Активная сессия", datePrevious: "Предыдущий день", dateNext: "Следующий день", dateToday: "Перейти к сегодняшней дате", cached: "Из локальной базы", fromApi: "Stat2TV", endpointFound: "Endpoint матчей найден", endpointNotFound: "Endpoint матчей пока не найден", statusScheduled: "Не начался", statusLive: "Идёт", statusFinished: "Завершён", series: "Серия", manualMatchTitle: "Загрузка по ID матча", manualMatchHint: "Укажите ID матча — лига и турнир будут определены автоматически по доступному JSON.", manualMatchPlaceholder: "Например, 901687", manualMatchButton: "Загрузить", manualMatchInvalid: "Укажите корректный ID матча", liveDataUpdated: "Данные матча обновлены", finishOvertime: "ОТ", finishShootout: "Б", }, en: { title: "Hockey", tournaments: "Tournaments", matches: "Games", today: "Today", all: "All tournaments", search: "Search tournaments…", league: "League", season: "Season", stage: "Stage", allValues: "All", noToday: "No active tournaments for this date", fallback: "Showing the latest available season", noItems: "No tournaments found", settings: "Stat2TV settings", sync: "Update tournaments", configured: "Connection configured", notConfigured: "Connection setup required", selectedTournament: "Selected tournament", selectedGame: "Selected game", changeTournament: "Change tournament", scheduleCached: "Schedule loaded · dates switch locally", chooseTournament: "Select tournament", liveUpdate: "Auto refresh 1 sec", updateStopped: "Game finished · refresh stopped", close: "Close", back: "Back to tournaments", save: "Save", test: "Test connection", importSeed: "Import attached XML", apiAddress: "API address", username: "Login", password: "New password", authMode: "Authentication", displayLanguage: "Web data language", fallbackLanguage: "Fallback language", interval: "Refresh interval, sec", verifySsl: "Verify SSL", credentialsHint: "Empty password keeps the current one. Credentials are stored locally on the server.", saved: "Settings saved", connectionOk: "Connection established", syncOk: "Tournaments updated", sourceSeed: "Attached XML data is currently used", sourceApi: "Last synchronization from Stat2TV", offSeason: "Off-season", gamesForDate: "Games on date", noGames: "No games on the selected date", noGamesLoaded: "The game calendar has not been loaded yet", updateGames: "Update games", discovering: "Discovering games XML…", loadingGames: "Loading schedule…", gamesLoaded: "Games loaded", openGame: "Open game", gameOpened: "Game opened", loadingMatchTitle: "Loading game", loadingMatchHint: "Please wait until loading is complete and do not click anything", loadingMatchDetails: "Loading rosters and game statistics…", loadingMatchSession: "Creating the operator session…", loadingMatchStandings: "Updating tournament data…", loadingMatchFinishing: "Preparing the interface…", endpointTemplate: "Games XML template", endpointHint: "Leave blank for automatic discovery. Variables: {tournament_id}, {date}, {season}.", endpointAuto: "Automatically discover games endpoint", cacheSeconds: "Games cache, sec", rediscover: "Rediscover endpoint", diagnostics: "Diagnostics", connections: "Connections", checkAgain: "Check again", deepMatchTest: "Check selected game", matchSelectionReady: "Game selection", remoteSyncReady: "Remote sync", database: "PostgreSQL", stat2tv: "Stat2TV", agentVmix: "Agent / vMix", selectedMatchCheck: "Selected game", optionalConnection: "not required to open a game", adminOnly: "Administrator only", startTime: "Start", arena: "Arena", gameNumber: "Game", score: "Score", unknownTeams: "Teams were not recognized", syncFailed: "Failed to load games", currentSession: "Active session", datePrevious: "Previous day", dateNext: "Next day", dateToday: "Go to today", cached: "Local database", fromApi: "Stat2TV", endpointFound: "Games endpoint found", endpointNotFound: "Games endpoint not found yet", statusScheduled: "Scheduled", statusLive: "Live", statusFinished: "Finished", series: "Series", manualMatchTitle: "Load by game ID", manualMatchHint: "Enter a game ID — the league and tournament will be detected automatically from the available JSON.", manualMatchPlaceholder: "For example, 901687", manualMatchButton: "Load", manualMatchInvalid: "Enter a valid game ID", liveDataUpdated: "Match data updated", finishOvertime: "OT", finishShootout: "SO", }, }; function t(key) { return text[state.language]?.[key] || text.ru[key] || key; } function escapeHtml(value) { return String(value ?? "") .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } function localDateIso() { const now = new Date(); const offset = now.getTimezoneOffset(); return new Date(now.getTime() - offset * 60000).toISOString().slice(0, 10); } function shiftDate(value, days) { const date = new Date(`${value}T12:00:00`); date.setDate(date.getDate() + days); const offset = date.getTimezoneOffset(); return new Date(date.getTime() - offset * 60000).toISOString().slice(0, 10); } function formatDate(value) { if (!value) return "—"; try { return new Intl.DateTimeFormat( state.language === "en" ? "en-GB" : "ru-RU", { day: "2-digit", month: "long", year: "numeric" } ).format(new Date(`${value}T12:00:00`)); } catch (_) { return value; } } function currentAgentDeviceId() { return String(window.HockeyAgentRuntime?.currentDeviceId?.() || localStorage.getItem(storage.vmixDevice) || "").trim(); } function rememberAssignedAgent(deviceId) { const clean = String(deviceId || "").trim(); if (!clean) return; localStorage.setItem(storage.vmixDevice, clean); try { window.HockeyAgentRuntime?.selectDevice?.(clean); } catch (_) {} } async function ensureSessionAgentAssignment(session) { const token = String(session?.token || localStorage.getItem(storage.session) || "").trim(); let assignment = session?.agent_assignment && typeof session.agent_assignment === "object" ? session.agent_assignment : null; if (assignment?.device_id) rememberAssignedAgent(assignment.device_id); if (assignment?.delivered) return assignment; if (!token) return assignment; // BUILD111: opening the web match is not proof that Agent received // match.assign. Recover from stale/empty localStorage only when routing is // unambiguous: the selected live Agent or exactly one live active Agent. let devicesPayload = null; try { devicesPayload = await request("/api/hockey/agents/devices", { timeoutMs: 3500 }); } catch (_) { return assignment; } const devices = Array.isArray(devicesPayload?.devices) ? devicesPayload.devices : []; const live = devices.filter((item) => ( item?.paired_to_me && item?.active_for_account && item?.online )); const preferredIds = [ String(assignment?.device_id || "").trim(), currentAgentDeviceId(), ].filter(Boolean); let target = null; for (const preferred of preferredIds) { target = live.find((item) => String(item.device_id || "") === preferred) || null; if (target) break; } if (!target && live.length === 1) target = live[0]; if (!target?.device_id) return assignment; try { const rebound = await request( `/api/hockey/agents/devices/${encodeURIComponent(target.device_id)}/select-session`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ session_token: token, apply_mapping: false }), timeoutMs: 5000, } ); assignment = rebound?.assignment || assignment; if (assignment?.device_id) rememberAssignedAgent(assignment.device_id); if (session && assignment) session.agent_assignment = assignment; return assignment; } catch (error) { console.warn("[Hockey] Agent match.assign retry failed:", error); return assignment; } } async function refreshSessionAgentMapping(session) { const assignment = await ensureSessionAgentAssignment(session); const deviceId = String(assignment?.device_id || "").trim(); if (!deviceId || !assignment?.delivered) { return { ok: false, reason: "agent_match_not_delivered" }; } return request( `/api/hockey/agents/devices/${encodeURIComponent(deviceId)}/apply-mapping`, { method: "POST", timeoutMs: 60000 } ); } async function ensureAccountScopedRuntimeState() { let user = null; try { user = await request("/api/hockey/me"); } catch (_) { return; } state.currentUser = user; state.isAdmin = Boolean(user?.is_admin); const editorButton = document.getElementById("runtimeEditorBtn"); editorButton?.classList.toggle("hidden", !state.isAdmin); document.body.dataset.hockeyAdmin = state.isAdmin ? "1" : "0"; const currentAccountId = String(user?.id || "").trim(); if (!currentAccountId) return; const previousAccountId = String(localStorage.getItem(storage.account) || "").trim(); if (previousAccountId && previousAccountId !== currentAccountId) { // Session tokens and Agent selection belong to one WFL account only. // Keeping them across logout/login can bind a new operator to the // previous account's match or vMix device. localStorage.removeItem(storage.session); localStorage.removeItem(storage.game); localStorage.removeItem(storage.vmixDevice); state.selectedGameId = ""; state.selectedGame = null; state.selectedMatchDetails = null; stopSelectedGamePolling(); try { window.HockeyAgentRuntime?.selectDevice?.(""); } catch (_) {} } localStorage.setItem(storage.account, currentAccountId); } async function request(path, options = {}) { const { timeoutMs = 15000, ...fetchOptions } = options; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetch(path, { cache: "no-store", credentials: "same-origin", ...fetchOptions, signal: controller.signal, }); let payload = {}; try { payload = await response.json(); } catch (_) {} if (!response.ok) { if (response.status === 401) { window.location.assign("/"); } const detail = payload.detail; throw new Error( typeof detail === "string" ? detail : detail?.message || `HTTP ${response.status}` ); } return payload; } catch (error) { if (error?.name === "AbortError") { // BUILD109: this helper is used for local DB/session/Agent endpoints too. // Calling every HTTP timeout a Stat2TV timeout hides the real subsystem. throw new Error("Превышено время ожидания ответа сервера"); } throw error; } finally { clearTimeout(timeout); } } function notify(message, error = false) { if (!error && window.HOCKEY_RUNTIME_TOASTS_ENABLED === false) return; let toast = document.querySelector(".hockey-module-toast"); if (!toast) { toast = document.createElement("div"); toast.className = "hockey-module-toast"; document.body.appendChild(toast); } toast.textContent = message; toast.classList.toggle("error", error); toast.classList.add("show"); clearTimeout(toast._hideTimer); toast._hideTimer = setTimeout(() => toast.classList.remove("show"), 3200); } function gameById(id) { return (state.games?.items || []).find( (item) => String(item?.external_id || item?.id || "") === String(id || "") ) || null; } function matchLabelById(id) { const game = gameById(id); if (!game) return ""; const home = game.home?.name || game.home_name || game.team1 || ""; const away = game.away?.name || game.away_name || game.team2 || ""; return [home, away].filter(Boolean).join(" — "); } function setMatchLoading(visible, { game = "", stage = "" } = {}) { const overlay = state.root?.querySelector("[data-match-loading]"); if (!overlay) return; const titleNode = overlay.querySelector("[data-match-loading-title]"); const gameNode = overlay.querySelector("[data-match-loading-game]"); const stageNode = overlay.querySelector("[data-match-loading-stage]"); const hintNode = overlay.querySelector("[data-match-loading-hint]"); if (titleNode) titleNode.textContent = t("loadingMatchTitle"); if (gameNode) { gameNode.textContent = game || ""; gameNode.classList.toggle("hidden", !game); } if (stageNode) stageNode.textContent = stage || t("loadingMatchDetails"); if (hintNode) hintNode.textContent = t("loadingMatchHint"); overlay.classList.toggle("show", Boolean(visible)); overlay.setAttribute("aria-hidden", visible ? "false" : "true"); document.documentElement.classList.toggle("hockey-match-loading-active", Boolean(visible)); } function updateMatchLoading(stage) { const overlay = state.root?.querySelector("[data-match-loading]"); const stageNode = overlay?.querySelector("[data-match-loading-stage]"); if (stageNode) stageNode.textContent = stage || t("loadingMatchDetails"); } function toolbarHost() { return boot.mode === "editor" ? document.querySelector(".topbar .top-actions") : document.querySelector(".runtime-actions"); } function ensureShell() { if (state.root) return; const toolbar = toolbarHost(); if (toolbar) { const chip = document.createElement("button"); chip.type = "button"; chip.className = "hockey-selected-chip placeholder"; chip.addEventListener("click", () => { state.view = state.selectedTournament ? "games" : "tournaments"; setOpen(true); renderView(); }); toolbar.prepend(chip); state.selectedChip = chip; } const root = document.createElement("div"); root.className = "hockey-navigation-root"; root.innerHTML = `
`; document.body.appendChild(root); state.root = root; state.drawer = root.querySelector(".hockey-navigation-drawer"); state.backdrop = root.querySelector(".hockey-navigation-backdrop"); state.backdrop.addEventListener("click", () => setOpen(false)); root.querySelector("[data-close-nav]")?.addEventListener("click", () => setOpen(false)); root.querySelector("[data-back]")?.addEventListener("click", () => { state.view = "tournaments"; renderView(); renderTournamentList(); }); root.querySelector("[data-open-settings]")?.addEventListener("click", openSettings); root.querySelectorAll("[data-open-connection-diagnostics]").forEach((button) => { button.addEventListener("click", () => showConnectionDiagnostics({ deep: false })); }); root.querySelector("[data-logout]")?.addEventListener("click", () => { const form = document.createElement("form"); form.method = "post"; form.action = "/logout"; document.body.appendChild(form); form.submit(); }); root.querySelector("[data-sync]")?.addEventListener("click", syncTournaments); root.querySelector("[data-seed-import]")?.addEventListener("click", importSeed); root.querySelector("[data-games-rediscover]")?.addEventListener("click", () => syncGames(true)); root.querySelector("[data-games-diagnostics]")?.addEventListener("click", () => showConnectionDiagnostics({ deep: false })); root.querySelectorAll("[data-manual-game-form]").forEach((form) => form.addEventListener("submit", (event) => { event.preventDefault(); const input = event.currentTarget.querySelector("[data-manual-game-id]"); const id = String(input?.value || "").trim(); if (!/^\d+$/.test(id)) { notify(t("manualMatchInvalid"), true); input?.focus(); return; } openGame(id, { manual: true }); })); root.querySelectorAll("[data-language]").forEach((button) => { button.addEventListener("click", async () => { const nextLanguage = button.dataset.language === "en" ? "en" : "ru"; if (state.language === nextLanguage) return; state.language = nextLanguage; localStorage.setItem(storage.language, state.language); renderStaticLabels(); // RU/EN in the navigation is the operator's live language switch, not // merely a cosmetic localStorage preference. Persist it server-side so // Mapping's SQL resolver sees the same language, then immediately // re-apply the active Mapping to this operator's vMix. try { await request("/api/hockey/preferences", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ display_language: state.language, vmix_language: state.language, tournament_external_id: String(state.selectedId || ""), }), }); const selectedAgent = currentAgentDeviceId(); const mappingRefresh = selectedAgent ? await request(`/api/hockey/agents/devices/${encodeURIComponent(selectedAgent)}/apply-mapping`, { method: "POST" }) : await request("/api/hockey/agents/apply-active-mapping", { method: "POST" }); if (mappingRefresh && mappingRefresh.ok === false && !["no_active_device", "no_match_assignment", "mapping_missing"].includes(String(mappingRefresh.reason || ""))) { notify(`vMix Mapping: ${mappingRefresh.reason || "не удалось применить"}`, true); } } catch (error) { notify(`Язык сохранён локально, но vMix не обновлён: ${error.message || error}`, true); } window.dispatchEvent(new CustomEvent("hockey:settings-updated", { detail: { settings: { ui_language: state.language } }, })); loadNavigation(); if (state.view === "games") loadGames({ autoSync: false }); if (state.selectedId) loadTeamSchedule({ force: true }); if (state.selectedGameId) reloadSelectedGameDetails(); if (state.selectedId) { loadTournamentStandings({ sync: false }); loadTournamentStatistics({ sync: false }); } }); }); root.querySelectorAll("[data-nav-mode]").forEach((button) => { button.addEventListener("click", () => { state.mode = button.dataset.navMode; renderTournamentList(); }); }); let searchTimer = null; root.querySelector("[data-nav-search]").addEventListener("input", (event) => { state.search = event.target.value; clearTimeout(searchTimer); searchTimer = setTimeout(loadNavigation, 250); }); root.querySelectorAll("[data-filter]").forEach((select) => { select.addEventListener("change", () => { state[select.dataset.filter] = select.value; loadNavigation(); }); }); root.querySelector("[data-game-date]").addEventListener("change", (event) => { setGameDate(event.target.value || localDateIso()); }); root.querySelectorAll("[data-date-shift]").forEach((button) => { button.addEventListener("click", () => { setGameDate(shiftDate(state.gameDate, Number(button.dataset.dateShift || 0))); }); }); root.querySelector("[data-date-today]").addEventListener("click", () => { setGameDate(localDateIso()); }); window.addEventListener("keydown", (event) => { if (event.key === "Escape" && state.open) { event.preventDefault(); setOpen(false); } }); } function setOpen(value) { state.open = Boolean(value); state.root?.classList.toggle("open", state.open); document.body.classList.toggle("hockey-navigation-open", state.open); window.dispatchEvent(new CustomEvent("hockey:navigation-toggle", { detail: { open: state.open }, })); if (state.open) { stopSelectedGamePolling(); if (!state.navigation) loadNavigation(); } else if (state.selectedGameId && !selectedGameIsFinished()) { scheduleSelectedGamePoll(250); } } function renderStaticLabels() { if (!state.root) return; state.root.querySelector("[data-nav-search]").placeholder = t("search"); state.root.querySelector('[data-nav-mode="today"]').textContent = t("today"); state.root.querySelector('[data-nav-mode="all"]').textContent = t("all"); state.root.querySelector('[data-filter="league"]').closest("label").querySelector("span").textContent = t("league"); state.root.querySelector('[data-filter="season"]').closest("label").querySelector("span").textContent = t("season"); state.root.querySelector('[data-filter="stage"]').closest("label").querySelector("span").textContent = t("stage"); state.root.querySelector("[data-game-date]").closest("label").querySelector("span").textContent = t("gamesForDate"); state.root.querySelector("[data-date-today]").textContent = t("today"); state.root.querySelector("[data-sync]")?.replaceChildren(t("sync")); state.root.querySelector("[data-seed-import]")?.replaceChildren(t("importSeed")); state.root.querySelector("[data-games-rediscover]")?.replaceChildren(t("rediscover")); state.root.querySelector("[data-games-diagnostics]")?.replaceChildren(t("diagnostics")); state.root.querySelectorAll("[data-manual-game-form]").forEach((manualForm) => { manualForm.querySelector("div > strong").textContent = t("manualMatchTitle"); manualForm.querySelector("div > small").textContent = t("manualMatchHint"); manualForm.querySelector("[data-manual-game-id]").placeholder = t("manualMatchPlaceholder"); manualForm.querySelector("button[type=submit]").textContent = t("manualMatchButton"); }); state.root.querySelectorAll("[data-language]").forEach((button) => { button.classList.toggle("active", button.dataset.language === state.language); }); renderView(); renderTournamentList(); renderGames(); renderSelectedChip(); } function renderView() { if (!state.root) return; const gamesView = state.view === "games"; state.root.querySelector('[data-view="tournaments"]').classList.toggle("hidden", gamesView); state.root.querySelector('[data-view="games"]').classList.toggle("hidden", !gamesView); state.root.querySelector("[data-back]").classList.toggle("hidden", !gamesView); state.root.querySelector("[data-nav-title]").textContent = gamesView ? t("matches") : t("tournaments"); if (gamesView) renderSelectedTournament(); } function tournamentQueryString() { const params = new URLSearchParams({ language: state.language, on_date: localDateIso(), limit: "1000", }); if (state.search.trim()) params.set("search", state.search.trim()); if (state.league) params.set("league", state.league); if (state.season) params.set("season", state.season); if (state.stage) params.set("stage", state.stage); return params.toString(); } async function loadNavigation() { ensureShell(); state.loading = true; renderTournamentLoading(); try { state.navigation = await request(`/api/hockey/navigation?${tournamentQueryString()}`); renderFilters(); renderConnection(); renderTournamentList(); await restoreSelection(); } catch (error) { renderTournamentError(error.message); } finally { state.loading = false; } } function renderTournamentLoading() { const list = state.root?.querySelector("[data-nav-list]"); if (!list) return; list.innerHTML = Array.from({ length: 6 }, () => `
`).join(""); } function renderTournamentError(message) { const list = state.root?.querySelector("[data-nav-list]"); if (!list) return; list.innerHTML = `
Stat2TV

${escapeHtml(message)}

`; } function renderFilters() { const filters = state.navigation?.filters; if (!filters) return; const allOption = ``; const league = state.root.querySelector('[data-filter="league"]'); league.innerHTML = allOption + filters.leagues.map((item) => ` `).join(""); league.value = state.league; const season = state.root.querySelector('[data-filter="season"]'); season.innerHTML = allOption + filters.seasons.map((item) => ` `).join(""); season.value = state.season; const stage = state.root.querySelector('[data-filter="stage"]'); stage.innerHTML = allOption + filters.stages.map((item) => ` `).join(""); stage.value = state.stage; } function renderConnection() { if (!state.isAdmin) return; const meta = state.navigation?.meta || {}; const bar = state.root?.querySelector("[data-connection-bar]"); if (!bar) return; const configured = Boolean(meta.credentials_configured); bar.classList.toggle("connected", configured); bar.querySelector("strong").textContent = configured ? (state.language === "en" ? "Stat2TV configured" : "Stat2TV настроен") : (state.language === "en" ? "Stat2TV credentials missing" : "Stat2TV: нет данных авторизации"); const last = meta.last_sync; bar.dataset.tooltip = last?.source === "stat2tv" && last?.status === "success" ? `${t("sourceApi")} · ${last.item_count || 0} · ${t("connections")}` : `${t("connections")} · ${t("adminOnly")}`; } function tournamentListForMode() { const nav = state.navigation || {}; if (state.mode === "all") return nav.items || []; return (nav.today?.length ? nav.today : nav.recommended) || []; } function groupTournaments(items) { const groups = new Map(); items.forEach((item) => { const key = `${item.level}|${item.league}`; if (!groups.has(key)) groups.set(key, { level: item.level, label: item.league, items: [] }); groups.get(key).items.push(item); }); return [...groups.values()]; } function renderTournamentList() { if (!state.root || !state.navigation) return; state.root.querySelectorAll("[data-nav-mode]").forEach((button) => { button.classList.toggle("active", button.dataset.navMode === state.mode); }); const meta = state.navigation.meta; const summary = state.root.querySelector("[data-nav-summary]"); if (state.mode === "today" && !meta.today_count) { summary.innerHTML = ` ${escapeHtml(t("offSeason"))} ${escapeHtml(t("fallback"))}: ${escapeHtml(meta.latest_season || "—")} `; } else { summary.innerHTML = ` ${escapeHtml(state.mode === "today" ? t("today") : t("all"))} ${state.mode === "today" ? meta.today_count : meta.shown} `; } const items = tournamentListForMode(); const list = state.root.querySelector("[data-nav-list]"); if (!items.length) { list.innerHTML = `
${escapeHtml(t("noItems"))}

${escapeHtml(state.mode === "today" ? t("noToday") : "")}

`; return; } list.innerHTML = groupTournaments(items).map((group) => `
${escapeHtml(group.level.toUpperCase())} ${escapeHtml(group.label)} ${group.items.length}
${group.items.map(tournamentCard).join("")}
`).join(""); list.querySelectorAll("[data-tournament-id]").forEach((button) => { button.addEventListener("click", () => openTournament(button.dataset.tournamentId)); }); } function tournamentCard(item) { const selected = String(item.id) === String(state.selectedId); const dates = item.start_date && item.end_date ? `${item.start_date} — ${item.end_date}` : item.start_date || item.end_date || "—"; return ` `; } async function restoreSelection() { if (!state.selectedId) return; try { state.selectedTournament = await request( `/api/hockey/tournaments/${encodeURIComponent(state.selectedId)}?language=${state.language}` ); applyTournamentData(); renderSelectedChip(); await loadTournamentStandings({ sync: false }); await loadTournamentStatistics({ sync: false }); } catch (_) {} if (state.selectedGameId) { try { const loaded = await request( `/api/hockey/games/${encodeURIComponent(state.selectedGameId)}/details?language=${state.language}` ); const restoredTournamentId = String(loaded.game?.tournament_external_id || ""); if (restoredTournamentId && restoredTournamentId !== String(state.selectedId || "")) { state.selectedId = restoredTournamentId; localStorage.setItem(storage.tournament, state.selectedId); try { state.selectedTournament = await request( `/api/hockey/tournaments/${encodeURIComponent(state.selectedId)}?language=${state.language}` ); } catch (_) { state.selectedTournament = null; } state.standings = null; state.tournamentStatistics = null; state.games = null; state.view = "games"; applyTournamentData(); } state.selectedGame = loaded.game; state.selectedMatchDetails = loaded.details || null; applyGameData(); await loadTournamentStandings({ sync: false }); renderSelectedChip(); startSelectedGamePolling(); } catch (_) { try { state.selectedGame = await request( `/api/hockey/games/${encodeURIComponent(state.selectedGameId)}?language=${state.language}` ); state.selectedMatchDetails = null; applyGameData(); await loadTournamentStandings({ sync: false }); renderSelectedChip(); startSelectedGamePolling(); } catch (_) {} } } } async function reloadSelectedGameDetails() { if (!state.selectedGameId) return; try { const loaded = await request( `/api/hockey/games/${encodeURIComponent(state.selectedGameId)}/details?language=${state.language}` ); state.selectedGame = loaded.game; state.selectedMatchDetails = loaded.details || null; applyGameData(); renderSelectedChip(); } catch (_) {} } async function openTournament(id) { try { if (String(id) !== String(state.selectedId)) { stopSelectedGamePolling(); state.selectedGame = null; state.selectedMatchDetails = null; state.standings = null; state.tournamentStatistics = null; state.selectedGameId = ""; localStorage.removeItem(storage.game); const oldToken = localStorage.getItem(storage.session); if (oldToken) { request(`/api/hockey/sessions/${encodeURIComponent(oldToken)}`, { method: "DELETE", }).catch(() => {}); localStorage.removeItem(storage.session); } } state.selectedTournament = await request( `/api/hockey/tournaments/${encodeURIComponent(id)}?language=${state.language}` ); state.selectedId = String(id); localStorage.setItem(storage.tournament, state.selectedId); state.games = null; state.teamSchedule = null; state.teamScheduleTournamentId = ""; state.view = "games"; applyTournamentData(); renderSelectedChip(); renderView(); await loadGames({ autoSync: true }); await loadTournamentStandings({ sync: true }); await loadTournamentStatistics({ sync: true }); await loadStatLabels(); } catch (error) { notify(error.message, true); } } function schedulePayload() { const current = state.games && typeof state.games === "object" ? state.games : { meta: {}, items: [] }; return { ...current, meta: { ...(current.meta || {}), date: state.gameDate, tournament_external_id: state.selectedId || "", count: Array.isArray(current.items) ? current.items.length : 0, }, items: Array.isArray(current.items) ? current.items : [], selected_date: state.gameDate, }; } function applyGamesData() { const schedule = schedulePayload(); window.UIBuilderRuntime?.patchData?.({ hockey: { schedule } }); window.dispatchEvent(new CustomEvent("hockey:schedule-updated", { detail: { schedule, language: state.language }, })); } function applyTournamentData() { if (!state.selectedTournament) return; window.UIBuilderRuntime?.patchData?.({ hockey: { selected_tournament: state.selectedTournament, schedule: schedulePayload(), team_schedule: state.teamSchedule || { meta: {}, items: [] }, standings: state.standings, tournament_statistics: state.tournamentStatistics, stat_labels: state.statLabels, language: { display: state.language, available: ["ru", "en"] }, }, }); window.dispatchEvent(new CustomEvent("hockey:tournament-selected", { detail: { tournament: state.selectedTournament, language: state.language }, })); } function standingsUrl({ sync = false } = {}) { const params = new URLSearchParams({ language: state.language }); if (state.selectedGameId) params.set("game_id", state.selectedGameId); return `/api/hockey/tournaments/${encodeURIComponent(state.selectedId)}/standings` + `${sync ? "/sync" : ""}?${params.toString()}`; } function applyStandingsData() { window.UIBuilderRuntime?.patchData?.({ hockey: { standings: state.standings }, }); } async function loadTournamentStandings({ sync = false } = {}) { if (!state.selectedId) return; try { const loaded = await request( standingsUrl({ sync }), sync ? { method: "POST", timeoutMs: 30000 } : {} ); state.standings = loaded.standings || loaded; applyStandingsData(); } catch (error) { if (sync) { try { const cached = await request(standingsUrl({ sync: false })); state.standings = cached.standings || cached; applyStandingsData(); } catch (_) {} } } } function tournamentStatisticsUrl({ sync = false, force = false } = {}) { const params = new URLSearchParams({ language: state.language }); if (force) params.set("force", "true"); return `/api/hockey/tournaments/${encodeURIComponent(state.selectedId)}/statistics` + `${sync ? "/sync" : ""}?${params.toString()}`; } function applyStatLabelsData() { window.UIBuilderRuntime?.patchData?.({ hockey: { stat_labels: state.statLabels }, }); } async function loadStatLabels({ force = false } = {}) { if (!state.selectedId) return; try { const suffix = force ? "?force=true" : ""; state.statLabels = await request( `/api/hockey/tournaments/${encodeURIComponent(state.selectedId)}/navigation-labels${suffix}` ); applyStatLabelsData(); } catch (_) { state.statLabels = null; applyStatLabelsData(); } } function applyTournamentStatisticsData() { window.UIBuilderRuntime?.patchData?.({ hockey: { tournament_statistics: state.tournamentStatistics }, }); } async function loadTournamentStatistics({ sync = false, force = false } = {}) { if (!state.selectedId) return; try { const loaded = await request( tournamentStatisticsUrl({ sync, force }), sync ? { method: "POST", timeoutMs: 30000 } : {} ); state.tournamentStatistics = loaded.statistics || loaded; applyTournamentStatisticsData(); } catch (_) { if (sync) { try { state.tournamentStatistics = await request( tournamentStatisticsUrl({ sync: false }) ); applyTournamentStatisticsData(); } catch (_) {} } } } function renderSelectedTournament() { const host = state.root?.querySelector("[data-selected-tournament]"); if (!host) return; const item = state.selectedTournament; if (!item) { host.innerHTML = ""; return; } host.innerHTML = `
${escapeHtml(item.league)} · ${escapeHtml(item.season || "")} ${escapeHtml(item.name || item.full_name || "")} ${escapeHtml(item.stage || "")}
`; host.querySelector("[data-change-tournament]")?.addEventListener("click", () => { state.view = "tournaments"; renderView(); renderTournamentList(); }); } function setGameDate(value) { state.gameDate = value || localDateIso(); localStorage.setItem(storage.gameDate, state.gameDate); const input = state.root?.querySelector("[data-game-date]"); if (input) input.value = state.gameDate; state.games = null; applyGamesData(); loadGames({ autoSync: false }); } function gamesUrl() { return `/api/hockey/tournaments/${encodeURIComponent(state.selectedId)}/games` + `?on_date=${encodeURIComponent(state.gameDate)}` + `&language=${encodeURIComponent(state.language)}`; } function allDatesGamesUrl() { return `/api/hockey/tournaments/${encodeURIComponent(state.selectedId)}/games` + `?on_date=${encodeURIComponent(state.gameDate)}` + `&language=${encodeURIComponent(state.language)}` + `&all_dates=true`; } function applyTeamScheduleData() { const current = state.teamSchedule && typeof state.teamSchedule === "object" ? state.teamSchedule : { meta: {}, items: [] }; const teamSchedule = { ...current, meta: { ...(current.meta || {}), tournament_external_id: state.selectedId || "", count: Array.isArray(current.items) ? current.items.length : 0, }, items: Array.isArray(current.items) ? current.items : [], }; window.UIBuilderRuntime?.patchData?.({ hockey: { team_schedule: teamSchedule } }); window.dispatchEvent(new CustomEvent("hockey:team-schedule-updated", { detail: { schedule: teamSchedule, language: state.language }, })); } async function loadTeamSchedule({ force = false } = {}) { if (!state.selectedId) return; if ( !force && state.teamSchedule && state.teamScheduleTournamentId === String(state.selectedId) ) { applyTeamScheduleData(); return; } try { state.teamSchedule = await request(allDatesGamesUrl()); state.teamScheduleTournamentId = String(state.selectedId); } catch (_) { state.teamSchedule = state.teamSchedule || { meta: {}, items: [] }; } applyTeamScheduleData(); } async function loadGames({ autoSync = false } = {}) { if (!state.selectedId) return; state.loading = true; renderGamesLoading(t("loadingGames")); try { state.games = await request(gamesUrl()); applyGamesData(); renderGames(); await loadTeamSchedule(); const syncKey = String(state.selectedId); if ( autoSync && state.games.meta.credentials_configured && !state.games.meta.schedule_loaded && !state.gamesAutoSyncAttempted.has(syncKey) && !state.scheduleSyncBusy ) { state.gamesAutoSyncAttempted.add(syncKey); await syncGames(false, { silentSuccess: true }); } } catch (error) { state.games = { meta: { date: state.gameDate, tournament_external_id: state.selectedId, count: 0 }, items: [] }; applyGamesData(); renderGamesError(error.message); } finally { state.loading = false; } } function renderGamesLoading(message) { const list = state.root?.querySelector("[data-games-list]"); const summary = state.root?.querySelector("[data-games-summary]"); if (summary) summary.innerHTML = `${escapeHtml(message)}`; if (list) { list.innerHTML = Array.from({ length: 4 }, () => `
`).join(""); } } function renderGamesError(message) { const list = state.root?.querySelector("[data-games-list]"); const summary = state.root?.querySelector("[data-games-summary]"); if (summary) summary.innerHTML = `${escapeHtml(t("syncFailed"))}`; if (list) { list.innerHTML = `
${escapeHtml(t("syncFailed"))}

${escapeHtml(message)}

${escapeHtml(state.isAdmin ? `${t("diagnostics")} · ${t("adminOnly")}` : "")}
`; } } function matchOpenErrorMarkup() { if (!state.lastGameOpenError) return ""; const diagnosticButton = state.isAdmin ? `` : ""; return `
${escapeHtml(state.language === "en" ? "Failed to open game" : "Не удалось открыть матч")}

${escapeHtml(state.lastGameOpenError)}

${diagnosticButton}
`; } function bindMatchErrorDiagnostics(root) { root?.querySelector("[data-match-error-diagnostics]")?.addEventListener("click", () => { showConnectionDiagnostics({ deep: true }); }); } function renderGames() { if (!state.root || state.view !== "games") return; renderSelectedTournament(); const input = state.root.querySelector("[data-game-date]"); input.value = state.gameDate; const summary = state.root.querySelector("[data-games-summary]"); const list = state.root.querySelector("[data-games-list]"); const items = state.games?.items || []; const meta = state.games?.meta || {}; summary.innerHTML = `
${escapeHtml(formatDate(state.gameDate))} ${items.length}
${escapeHtml(meta.endpoint_template ? t("endpointFound") : t("endpointNotFound"))} `; if (!items.length) { list.innerHTML = `${matchOpenErrorMarkup()}
${escapeHtml(meta.last_synced_at ? t("noGames") : t("noGamesLoaded"))}

${escapeHtml(formatDate(state.gameDate))}

${escapeHtml( meta.schedule_loaded ? t("scheduleCached") : meta.credentials_configured ? t("loadingGames") : state.isAdmin ? t("notConfigured") : t("noGamesLoaded") )}
`; bindMatchErrorDiagnostics(list); return; } list.innerHTML = `${matchOpenErrorMarkup()}${items.map(gameCard).join("")}`; bindMatchErrorDiagnostics(list); list.querySelectorAll("[data-game-id]").forEach((button) => { button.addEventListener("click", () => openGame(button.dataset.gameId)); }); } function gameFinishLabel(game) { const direct = String(game?.score?.finish_label || "").trim(); if (direct) return direct; const finishType = String(game?.score?.finish_type || "").toUpperCase(); if (finishType === "SO") return t("finishShootout"); if (finishType === "OT") return t("finishOvertime"); return ""; } function gameStatusLabel(game) { if (game?.is_finished || game?.status === "finished") { const finish = gameFinishLabel(game); return `${t("statusFinished")}${finish ? ` · ${finish}` : ""}`; } if (game?.status === "live") return t("statusLive"); if (game?.status === "scheduled") return t("statusScheduled"); return game?.status || ""; } function gameCard(game) { const selected = String(game.external_id) === String(state.selectedGameId); const homeName = game.home?.name || t("unknownTeams"); const awayName = game.away?.name || t("unknownTeams"); const showScore = ( game.is_finished || game.status === "live" || Number(game.home?.score) || Number(game.away?.score) ); const round = game.round?.name || ""; const series = game.score?.series || ""; const status = gameStatusLabel(game); return ` `; } async function syncGames(forceDiscovery, { silentSuccess = false } = {}) { if (!state.selectedId || state.scheduleSyncBusy) return; state.scheduleSyncBusy = true; const rediscover = state.root?.querySelector("[data-games-rediscover]"); if (rediscover) rediscover.disabled = true; renderGamesLoading(forceDiscovery ? t("discovering") : t("loadingGames")); try { const result = await request( `/api/hockey/tournaments/${encodeURIComponent(state.selectedId)}/games/sync` + `?on_date=${encodeURIComponent(state.gameDate)}` + `&force_discovery=${forceDiscovery ? "true" : "false"}`, { method: "POST" } ); if (!silentSuccess) { const visibleCount = Number.isFinite(Number(result.selected_date_count)) ? Number(result.selected_date_count) : Number(result.count || 0); const fallbackLabel = result.fallback_used ? (state.language === "en" ? " · tournament API fallback" : " · резервный API турнира") : ""; notify(`${t("gamesLoaded")}: ${visibleCount} · ${result.endpoint}${fallbackLabel}`); } state.gamesAutoSyncAttempted.add(String(state.selectedId)); state.games = await request(gamesUrl()); applyGamesData(); renderGames(); await loadTeamSchedule({ force: true }); } catch (error) { state.gamesAutoSyncAttempted.delete(String(state.selectedId)); renderGamesError(error.message); notify(error.message, true); } finally { state.scheduleSyncBusy = false; if (rediscover) rediscover.disabled = false; } } async function enrichOpenedGame(id, tournamentId, generation, { opening = false } = {}) { try { const loaded = await request( `/api/hockey/games/${encodeURIComponent(id)}/details/sync` + `?tournament_id=${encodeURIComponent(tournamentId)}` + `&language=${encodeURIComponent(state.language)}` + `&opening=${opening ? "true" : "false"}`, { method: "POST", timeoutMs: opening ? 14000 : 16000 } ); if ( generation !== state.gameOpenGeneration || String(state.selectedGameId || "") !== String(id) ) return false; if (loaded?.game) state.selectedGame = loaded.game; state.selectedMatchDetails = loaded?.details || state.selectedMatchDetails || null; applyGameData(); renderSelectedChip(); renderGames(); if (loaded?.details_sync_failed || loaded?.degraded) { console.warn("[Hockey] Match opened with cached/schedule data; full details are not available yet", loaded?.warnings || []); } return Boolean(loaded?.game); } catch (error) { // Full match JSON, rosters, shots and directories are enrichment data. // Their absence must never undo a match already selected by the operator. console.warn(`[Hockey] Optional match details sync failed for ${id}:`, error); return false; } } function clearRuntimeMatchSpecificData() { // BUILD110: patchData() performs a deep merge. A schedule row normally has // no home.players/away.players fields, so without an explicit reset the // previous match roster survives until the new details request completes. window.UIBuilderRuntime?.patchData?.({ hockey: { selected_game: null, home: null, away: null, referees: [], match_details: null, game_control: null, selected_penalties: { home: null, away: null }, active_penalties: { home: null, away: null }, }, }); } function cachedMatchHasRosters(payload) { if (!payload?.game || !payload?.details?.loaded) return false; const home = Array.isArray(payload.game?.home?.players) ? payload.game.home.players.length : 0; const away = Array.isArray(payload.game?.away?.players) ? payload.game.away.players.length : 0; return (home + away) > 0; } async function openGame(id, { manual = false } = {}) { if (state.gameOpenBusy) return; state.lastAttemptedGameId = String(id || ""); state.lastGameOpenError = ""; state.gameOpenBusy = true; stopSelectedGamePolling(); const generation = ++state.gameOpenGeneration; const loadingGame = matchLabelById(id) || `${t("gameNumber")} ID ${id}`; setMatchLoading(true, { game: loadingGame, stage: t("loadingMatchDetails") }); try { // Match selection itself depends only on the local/cached schedule row. // Full Stat2TV match JSON is optional enrichment and is loaded later. const scheduleGame = gameById(id); if (manual) { // Manual ID entry still performs global tournament discovery because the // game may not exist in the local schedule yet. const discovered = await request( `/api/hockey/games/manual/${encodeURIComponent(id)}?language=${encodeURIComponent(state.language)}`, { method: "POST", timeoutMs: 60000 } ); state.selectedGame = discovered.game; state.selectedMatchDetails = discovered.details || null; } else if (scheduleGame) { // BUILD109: the card the operator clicked is already a complete local // schedule snapshot. Opening the workspace must be immediate; cached // details/full match data are optional enrichment and are loaded below. state.selectedGame = scheduleGame; state.selectedMatchDetails = null; } else { // Defensive fallback for a stale DOM/list state. This path is local DB // only and is not used for the normal visible match-card click. state.selectedGame = await request( `/api/hockey/games/${encodeURIComponent(id)}?language=${encodeURIComponent(state.language)}`, { timeoutMs: 5000 } ); state.selectedMatchDetails = null; } const actualTournamentId = String( state.selectedGame?.tournament_external_id || state.selectedId || "" ).trim(); if (!actualTournamentId) throw new Error("Не удалось определить турнир выбранного матча"); if (actualTournamentId !== String(state.selectedId || "")) { state.selectedId = actualTournamentId; localStorage.setItem(storage.tournament, state.selectedId); try { state.selectedTournament = await request( `/api/hockey/tournaments/${encodeURIComponent(state.selectedId)}?language=${encodeURIComponent(state.language)}`, { timeoutMs: 5000 } ); } catch (_) { state.selectedTournament = state.selectedTournament || null; } state.standings = null; state.tournamentStatistics = null; state.games = null; state.teamSchedule = null; state.teamScheduleTournamentId = ""; applyTournamentData(); } if (manual && state.selectedGame?.tournament_external_id) { state.selectedId = String(state.selectedGame.tournament_external_id); localStorage.setItem(storage.tournament, state.selectedId); } // Read only the selected game from the local DB while the session is being // created. This never contacts Stat2TV and lets a previously loaded match // restore its own roster immediately, without exposing another game's data. const cachedDetailsPromise = manual ? Promise.resolve(null) : request( `/api/hockey/games/${encodeURIComponent(id)}/details?language=${encodeURIComponent(state.language)}`, { timeoutMs: 2000 } ).catch(() => null); updateMatchLoading(t("loadingMatchSession")); const previousToken = localStorage.getItem(storage.session); if (previousToken) { try { const oldSession = await request(`/api/hockey/sessions/${encodeURIComponent(previousToken)}`); if (String(oldSession.game_external_id || "") !== String(id)) { await request(`/api/hockey/sessions/${encodeURIComponent(previousToken)}`, { method: "DELETE" }); localStorage.removeItem(storage.session); } } catch (_) { localStorage.removeItem(storage.session); } } let session = null; const selectedDeviceId = currentAgentDeviceId(); const sessionPayload = { tournament_external_id: state.selectedId, game_external_id: String(id), display_language: state.language, vmix_language: state.language, vmix_device_id: selectedDeviceId, replace_existing: true, }; try { session = await request("/api/hockey/sessions", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(sessionPayload), }); } catch (sessionError) { // Retry the operator session without Agent if the browser carries a stale device. if (selectedDeviceId) { localStorage.removeItem(storage.vmixDevice); try { window.HockeyAgentRuntime?.selectDevice?.(""); } catch (_) {} session = await request("/api/hockey/sessions", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...sessionPayload, vmix_device_id: "" }), }); } else { throw sessionError; } } // Confirm that the concrete Agent actually received this new match. // A successful /sessions response alone is not enough: assignment may be // null/offline when the browser stored a stale device id. const initialAgentAssignment = await ensureSessionAgentAssignment(session); if (initialAgentAssignment && session) session.agent_assignment = initialAgentAssignment; state.lastGameOpenError = ""; state.selectedGameId = String(id); localStorage.setItem(storage.game, state.selectedGameId); if (session?.token) localStorage.setItem(storage.session, session.token); else localStorage.removeItem(storage.session); const selectedGameDate = String(state.selectedGame?.date || "").trim(); if (selectedGameDate) { state.gameDate = selectedGameDate; localStorage.setItem(storage.gameDate, state.gameDate); } // Prefer cached details for this exact game. The Runtime was explicitly // cleared above, so even a missing cache can only show an empty new match, // never the previous match roster. // The new session is confirmed. From this point the old match-specific // Runtime payload must not survive even for a single render. clearRuntimeMatchSpecificData(); const cachedDetails = await cachedDetailsPromise; const cachedRostersReady = cachedMatchHasRosters(cachedDetails); if (cachedDetails?.game) { state.selectedGame = cachedDetails.game; state.selectedMatchDetails = cachedDetails.details || null; } applyGameData(session); renderSelectedChip(); setOpen(false); // For a first-time match, keep the loading overlay until the critical // roster card has arrived. Mapping and optional shots remain non-blocking. // If this game's own cached roster already exists, let the operator work // immediately and refresh it in the background. if (!manual && !cachedRostersReady) { updateMatchLoading(t("loadingMatchDetails")); await enrichOpenedGame(String(id), state.selectedId, generation, { opening: true }); if ( generation === state.gameOpenGeneration && String(state.selectedGameId || "") === String(id) ) { startSelectedGamePolling(); void refreshSessionAgentMapping(session).then((mapping) => { if (mapping?.ok === false && mapping?.reason === "agent_match_not_delivered") { notify("Матч открыт, но Agent не получил новый матч", true); } }).catch((error) => console.warn("[Hockey] Mapping refresh after roster failed:", error)); } } else if (!manual) { // This exact match already has its own cached roster, so vMix can be // refreshed immediately without waiting for the optional network sync. void refreshSessionAgentMapping(session).then((mapping) => { if (mapping?.ok === false && mapping?.reason === "agent_match_not_delivered") { notify("Матч открыт, но Agent не получил новый матч", true); } }).catch((error) => console.warn("[Hockey] Mapping refresh from cached roster failed:", error)); void enrichOpenedGame(String(id), state.selectedId, generation, { opening: true }) .finally(() => { if ( generation === state.gameOpenGeneration && String(state.selectedGameId || "") === String(id) ) { startSelectedGamePolling(); } }); } else { startSelectedGamePolling(); void refreshSessionAgentMapping(session).catch((error) => console.warn("[Hockey] Mapping refresh for manual match failed:", error)); } updateMatchLoading(t("loadingMatchFinishing")); loadTournamentStandings({ sync: false }).catch(() => {}); if (selectedGameDate) loadGames({ autoSync: false }).catch(() => {}); const playersCount = (state.selectedGame?.home?.players?.length || 0) + (state.selectedGame?.away?.players?.length || 0); const refereesCount = state.selectedGame?.referees?.length || 0; const loadedText = state.selectedMatchDetails?.loaded ? ` · ${playersCount} ${state.language === "en" ? "players" : "игроков"}` + ` · ${refereesCount} ${state.language === "en" ? "officials" : "судей"}` : ""; const homeName = state.selectedGame?.home?.name || "—"; const awayName = state.selectedGame?.away?.name || "—"; notify(`${t("gameOpened")}: ${homeName} — ${awayName}${loadedText}`); if (manual) state.root?.querySelectorAll("[data-manual-game-id]").forEach((manualInput) => { manualInput.value = ""; }); } catch (error) { state.lastGameOpenError = String(error?.message || error || "Неизвестная ошибка"); if (state.view === "games") renderGames(); notify(state.lastGameOpenError, true); } finally { setMatchLoading(false); state.gameOpenBusy = false; } } function applyGameData(session = null) { if (!state.selectedGame) return; const token = session?.token || localStorage.getItem(storage.session) || ""; window.UIBuilderRuntime?.patchData?.({ hockey: { selected_game: state.selectedGame, schedule: schedulePayload(), team_schedule: state.teamSchedule || { meta: {}, items: [] }, home: state.selectedGame.home || { players: [] }, away: state.selectedGame.away || { players: [] }, referees: state.selectedGame.referees || [], match_details: state.selectedMatchDetails || null, standings: state.standings, tournament_statistics: state.tournamentStatistics, stat_labels: state.statLabels, operator_session: session || (token ? { token } : null), }, }); window.dispatchEvent(new CustomEvent("hockey:game-selected", { detail: { game: state.selectedGame, details: state.selectedMatchDetails, session: session || (token ? { token } : null), language: state.language, }, })); } function renderSelectedChip() { if (!state.selectedChip) return; state.selectedChip.classList.remove("hidden", "placeholder", "polling", "finished"); if (state.selectedGame) { const finished = Boolean(state.selectedGame.is_finished); state.selectedChip.classList.toggle("finished", finished); state.selectedChip.classList.toggle("polling", !finished); state.selectedChip.dataset.tooltip = finished ? t("updateStopped") : t("liveUpdate"); state.selectedChip.innerHTML = ` ${finished ? "✓" : ""} ${escapeHtml(state.selectedGame.time || t("matches"))} ${escapeHtml(state.selectedGame.home.name)} — ${escapeHtml(state.selectedGame.away.name)} `; return; } if (state.selectedTournament) { state.selectedChip.dataset.tooltip = t("selectedTournament"); state.selectedChip.innerHTML = ` ${escapeHtml(state.selectedTournament.league)} ${escapeHtml(state.selectedTournament.name)} `; return; } state.selectedChip.classList.add("placeholder"); state.selectedChip.dataset.tooltip = t("chooseTournament"); state.selectedChip.innerHTML = ` STAT2TV ${escapeHtml(t("chooseTournament"))} `; } function stopSelectedGamePolling() { if (state.gamePollTimer) { clearTimeout(state.gamePollTimer); state.gamePollTimer = null; } state.gamePollBusy = false; } function selectedGameIsFinished() { return Boolean(state.selectedGame?.is_finished); } function scheduleSelectedGamePoll(delay = 1000) { stopSelectedGamePolling(); if (!state.selectedGameId || selectedGameIsFinished()) { renderSelectedChip(); return; } state.gamePollTimer = setTimeout(pollSelectedGame, Math.max(250, delay)); } function startSelectedGamePolling() { state.gamePollErrorCount = 0; state.gamePollStoppedGameId = ""; if (selectedGameIsFinished()) { stopSelectedGamePolling(); state.gamePollStoppedGameId = state.selectedGameId; renderSelectedChip(); return; } scheduleSelectedGamePoll(1000); } async function pollSelectedGame() { if ( state.gamePollBusy || !state.selectedGameId || !state.selectedId || state.open || selectedGameIsFinished() ) { scheduleSelectedGamePoll(1000); return; } if (document.hidden) { scheduleSelectedGamePoll(1000); return; } state.gamePollBusy = true; try { const live = await request( `/api/hockey/games/${encodeURIComponent(state.selectedGameId)}/live` + `?tournament_id=${encodeURIComponent(state.selectedId)}` + `&language=${encodeURIComponent(state.language)}`, { method: "POST", timeoutMs: 8000 } ); const updated = live.game; const previous = state.selectedGame || {}; if (live.changed && updated) { state.selectedGame = updated; state.selectedMatchDetails = live.details || state.selectedMatchDetails; const sections = Array.isArray(live.changed_sections) && live.changed_sections.length ? `: ${live.changed_sections.join(", ")}` : ""; notify(`${t("liveDataUpdated")}${sections}`); } else { state.selectedGame = { ...previous, ...(updated || {}), home: { ...(previous.home || {}), ...(updated?.home || {}), players: previous.home?.players || [] }, away: { ...(previous.away || {}), ...(updated?.away || {}), players: previous.away?.players || [] }, referees: previous.referees || [], }; } state.gamePollErrorCount = 0; applyGameData(); renderSelectedChip(); if (state.games?.items?.length) { const index = state.games.items.findIndex( (item) => String(item.external_id) === String(updated.external_id) ); if (index >= 0) { state.games.items[index] = updated; renderGames(); } } if (updated.is_finished) { stopSelectedGamePolling(); state.gamePollStoppedGameId = updated.external_id; renderSelectedChip(); return; } scheduleSelectedGamePoll(Number(updated.refresh_interval_ms || 1000)); } catch (error) { state.gamePollErrorCount += 1; const backoff = Math.min(10000, 1000 * Math.max(2, state.gamePollErrorCount)); scheduleSelectedGamePoll(backoff); } finally { state.gamePollBusy = false; } } document.addEventListener("visibilitychange", () => { if ( !document.hidden && state.selectedGameId && !selectedGameIsFinished() ) { scheduleSelectedGamePoll(150); } }); function diagnosticStatus(ok) { return `${ok ? "● OK" : "● ERROR"}`; } function diagnosticMessage(value) { if (!value) return "—"; if (value.error) return String(value.error); if (value.url) return String(value.url); return "—"; } async function showConnectionDiagnostics({ deep = false } = {}) { if (!state.isAdmin) return; const tournamentId = String(state.selectedId || "").trim(); const gameId = String(state.selectedGameId || state.lastAttemptedGameId || "").trim(); const query = new URLSearchParams({ tournament_id: tournamentId, game_id: gameId, language: state.language, deep: deep ? "true" : "false", }); let modal = document.querySelector(".hockey-connection-diagnostics-modal"); if (!modal) { modal = document.createElement("div"); modal.className = "hockey-diagnostics-modal hockey-connection-diagnostics-modal"; modal.innerHTML = `
ADMIN / HEALTH${escapeHtml(t("connections"))}
`; document.body.appendChild(modal); const close = () => modal.remove(); modal.querySelector("[data-close]")?.addEventListener("click", close); modal.querySelector(".hockey-settings-backdrop")?.addEventListener("click", close); modal.querySelector("[data-refresh]")?.addEventListener("click", () => showConnectionDiagnostics({ deep: false })); modal.querySelector("[data-deep]")?.addEventListener("click", () => showConnectionDiagnostics({ deep: true })); } const content = modal.querySelector("[data-connection-diagnostics-content]"); const deepButton = modal.querySelector("[data-deep]"); if (deepButton) deepButton.disabled = !(gameId && tournamentId); if (content) content.innerHTML = `
${escapeHtml(deep ? t("deepMatchTest") : t("checkAgain"))}…
`; try { const payload = await request(`/api/hockey/admin/connection-diagnostics?${query.toString()}`, { timeoutMs: deep ? 26000 : 16000 }); const checks = payload.checks || {}; const database = checks.database || {}; const stat2tv = checks.stat2tv || {}; const agents = checks.agents || {}; const settings = checks.settings || {}; const match = checks.match || null; const tournament = checks.tournament || null; const selection = payload.required_for_match_selection || {}; const remote = payload.required_for_remote_sync || {}; const agentDevices = Array.isArray(agents.devices) ? agents.devices : []; const warningItems = Array.isArray(match?.warnings) ? match.warnings : []; content.innerHTML = `
${escapeHtml(t("matchSelectionReady"))} ${selection.ok ? "OK" : "ERROR"} ${escapeHtml(state.language === "en" ? "Depends on PostgreSQL" : "Зависит от PostgreSQL")}
${escapeHtml(t("remoteSyncReady"))} ${remote.ok ? "OK" : "ERROR"} ${escapeHtml(state.language === "en" ? "PostgreSQL + Stat2TV" : "PostgreSQL + Stat2TV")}
${escapeHtml(t("database"))}${diagnosticStatus(Boolean(database.ok))}
Latency
${escapeHtml(database.latency_ms ?? "—")} ms
Host
${escapeHtml(database.target?.host || "—")}
Database
${escapeHtml(database.target?.database || "—")}
Error
${escapeHtml(database.error || "—")}
${escapeHtml(t("stat2tv"))}${diagnosticStatus(Boolean(stat2tv.ok))}
Configured
${settings.credentials_configured ? "YES" : "NO"}
Credentials source
${escapeHtml(settings.credentials_source || "missing")}
Login
${escapeHtml(settings.username_masked || "—")}
Base URL
${escapeHtml(settings.base_url || "—")}
HTTP
${escapeHtml(stat2tv.status_code ?? "—")}
Items
${escapeHtml(stat2tv.items_found ?? "—")}
Latency
${escapeHtml(stat2tv.latency_ms ?? "—")} ms
Error
${escapeHtml(diagnosticMessage(stat2tv))}
${escapeHtml(t("agentVmix"))}${diagnosticStatus(Boolean(agents.ok))}

${escapeHtml(t("optionalConnection"))}

Agents online
${escapeHtml(agents.online ?? 0)}
vMix connected
${escapeHtml(agents.vmix_connected ?? 0)}
Error
${escapeHtml(agents.error || "—")}
${agentDevices.length ? `
${agentDevices.map((item) => `
${escapeHtml(item.name || item.device_id || "Agent")}${item.online ? "ONLINE" : "OFFLINE"} · vMix ${item.vmix_connected ? "OK" : "OFF"}${item.owner ? ` · ${escapeHtml(item.owner)}` : ""}
`).join("")}
` : ""}
${tournament ? `
${escapeHtml(state.language === "en" ? "Tournament" : "Турнир")} ${escapeHtml(tournament.tournament_id || "")}${diagnosticStatus(Boolean(tournament.ok))}
Local
${tournament.local_found ? "FOUND" : "NOT FOUND"}
` : ""} ${match ? `
${escapeHtml(t("selectedMatchCheck"))} ${escapeHtml(match.game_id || "")}${diagnosticStatus(Boolean(match.ok))}
Local
${match.local_found ? "FOUND" : "NOT FOUND"}
Deep
${match.deep_requested ? "YES" : "NO"}
Details
${match.details_loaded ? "LOADED" : (match.deep_requested ? "NOT LOADED" : "—")}
Degraded
${match.degraded ? "YES" : "NO"}
Latency
${escapeHtml(match.latency_ms ?? "—")} ms
Error
${escapeHtml(match.error || "—")}
${warningItems.length ? `
${warningItems.map((item) => `

${escapeHtml(item)}

`).join("")}
` : ""}
` : ""} ${state.lastGameOpenError ? `
${escapeHtml(state.language === "en" ? "Last open error" : "Последняя ошибка открытия")}

${escapeHtml(state.lastGameOpenError)}

` : ""} `; } catch (error) { if (content) content.innerHTML = `
${escapeHtml(t("diagnostics"))}

${escapeHtml(error.message)}

`; } } async function showEndpointDiagnostics() { if (!state.selectedId) return; try { const payload = await request( `/api/hockey/tournaments/${encodeURIComponent(state.selectedId)}/games/diagnostics` ); const latest = payload.items?.[0] || null; const modal = document.createElement("div"); modal.className = "hockey-diagnostics-modal"; modal.innerHTML = `
STAT2TV${escapeHtml(t("diagnostics"))}
${latest ? `
Status
${escapeHtml(latest.status)}
Template
${escapeHtml(latest.template || "—")}
Endpoint
${escapeHtml(latest.endpoint || "—")}
HTTP
${escapeHtml(latest.status_code || "—")}
XML root
${escapeHtml(latest.root_tag || "—")}
Item tag
${escapeHtml(latest.item_tag || "—")}
Items
${escapeHtml(latest.item_count || 0)}
Snapshot
${escapeHtml(latest.snapshot_path || "—")}
Error
${escapeHtml(latest.error || "—")}
${escapeHtml(JSON.stringify(latest.diagnostics || {}, null, 2))}
` : `
${escapeHtml(t("endpointNotFound"))}
`}
`; document.body.appendChild(modal); const close = () => modal.remove(); modal.querySelector("[data-close]").addEventListener("click", close); modal.querySelector(".hockey-settings-backdrop").addEventListener("click", close); } catch (error) { notify(error.message, true); } } async function syncTournaments() { const button = state.root.querySelector("[data-sync]"); button.disabled = true; try { const result = await request("/api/hockey/sync/tournaments", { method: "POST" }); notify(`${t("syncOk")}: ${result.count}`); await loadNavigation(); } catch (error) { notify(error.message, true); } finally { button.disabled = false; } } async function importSeed() { const button = state.root.querySelector("[data-seed-import]"); button.disabled = true; try { const result = await request("/api/hockey/sync/tournaments/seed", { method: "POST" }); notify(`${t("syncOk")}: ${result.count}`); await loadNavigation(); } catch (error) { notify(error.message, true); } finally { button.disabled = false; } } function loadSettingsCached({ force = false } = {}) { const fresh = state.settings && (Date.now() - Number(state.settingsLoadedAt || 0)) < 30000; if (!force && fresh) return Promise.resolve(state.settings); if (state.settingsPromise) return state.settingsPromise; state.settingsPromise = request("/api/hockey/settings", { timeoutMs: 5000 }) .then((payload) => { state.settings = payload || {}; state.settingsLoadedAt = Date.now(); return state.settings; }) .finally(() => { state.settingsPromise = null; }); return state.settingsPromise; } async function openSettings() { let loadingModal = null; if (!state.settings) { loadingModal = document.createElement("div"); loadingModal.className = "hockey-settings-modal"; loadingModal.innerHTML = `
STAT2TV${escapeHtml(t("settings"))}

${state.language === "en" ? "Loading settings…" : "Загрузка настроек…"}

`; document.body.appendChild(loadingModal); } try { state.settings = await loadSettingsCached(); } catch (error) { loadingModal?.remove(); notify(error.message, true); return; } loadingModal?.remove(); const value = state.settings; const modal = document.createElement("div"); modal.className = "hockey-settings-modal"; modal.innerHTML = `
STAT2TV${escapeHtml(t("settings"))}
${state.language === "en" ? "GAME CLOCK" : "ТАЙМЕР МАТЧА"} ${state.language === "en" ? "Default duration for a new game and automatic reset when the period changes." : "Длительность по умолчанию для нового матча и автоматический сброс при смене периода."}
${state.language === "en" ? "PENALTY / STRENGTH GRAPHIC" : "УДАЛЕНИЯ И ЧИСЛЕННЫЕ СОСТАВЫ"} ${state.language === "en" ? "Choose the base skater count and what the advantaged team receives in the graphic." : "Настройте базовый состав и подпись, которую получает команда в большинстве."}
${state.language === "en" ? "Bilingual strength labels" : "Двуязычные подписи составов"} ${state.language === "en" ? "Configure every 5×4, 4×4, 5×3, 4×3 and overtime label in Gear → Timers and lineups." : "Подписи для 5×4, 4×4, 5×3, 4×3 и всех вариантов овертайма настраиваются в разделе Шестерёнка → Таймеры и составы."}

${escapeHtml(t("credentialsHint"))}

`; document.body.appendChild(modal); state.settingsModal = modal; const form = modal.querySelector("form"); form.elements.auth_mode.value = value.auth_mode || "auto"; form.elements.ui_language.value = value.ui_language || "ru"; form.elements.fallback_language.value = value.fallback_language || "ru"; const close = () => { modal.remove(); state.settingsModal = null; }; modal.querySelector("[data-settings-close]").addEventListener("click", close); modal.querySelector(".hockey-settings-backdrop").addEventListener("click", close); modal.querySelector("[data-test-connection]").addEventListener("click", async () => { const status = modal.querySelector("[data-settings-status]"); status.textContent = "…"; try { const result = await request("/api/hockey/connection/test", { method: "POST" }); status.textContent = `${t("connectionOk")} · ${result.items_found}`; status.classList.remove("error"); } catch (error) { status.textContent = error.message; status.classList.add("error"); } }); form.addEventListener("submit", async (event) => { event.preventDefault(); const status = modal.querySelector("[data-settings-status]"); const values = new FormData(form); try { const savedSettings = await request("/api/hockey/settings", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ base_url: values.get("base_url"), auth_mode: values.get("auth_mode"), ui_language: values.get("ui_language"), fallback_language: values.get("fallback_language"), sync_interval_seconds: Number(values.get("sync_interval_seconds") || 300), games_cache_seconds: Number(values.get("games_cache_seconds") || 300), timer_period_minutes: Number(values.get("timer_period_minutes") || 20), timer_regular_overtime_minutes: Number(values.get("timer_regular_overtime_minutes") || 5), timer_playoff_overtime_minutes: Number(values.get("timer_playoff_overtime_minutes") || 20), timer_reset_on_period_change: Boolean(values.get("timer_reset_on_period_change")), strength_regulation_skaters: Number(values.get("strength_regulation_skaters") || 5), strength_regular_overtime_skaters: Number(values.get("strength_regular_overtime_skaters") || 3), strength_playoff_overtime_skaters: Number(values.get("strength_playoff_overtime_skaters") || 5), strength_min_skaters: Number(values.get("strength_min_skaters") || 3), games_endpoint_template: values.get("games_endpoint_template"), players_endpoint_template: values.get("players_endpoint_template"), player_countries_endpoint_template: values.get("player_countries_endpoint_template"), referees_endpoint_template: values.get("referees_endpoint_template"), shots_endpoint_template: values.get("shots_endpoint_template"), games_auto_discovery: Boolean(values.get("games_auto_discovery")), verify_ssl: Boolean(values.get("verify_ssl")), }), }); state.settings = savedSettings || state.settings || {}; state.settingsLoadedAt = Date.now(); const username = String(values.get("username") || "").trim(); const password = String(values.get("password") || ""); if (username || password) { await request("/api/hockey/credentials", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: username || null, password: password || null, }), }); } const previousLanguage = String(state.language || "ru").toLowerCase(); const nextLanguage = String(values.get("ui_language") || state.language || "ru").toLowerCase(); state.language = nextLanguage; localStorage.setItem(storage.language, state.language); window.dispatchEvent(new CustomEvent("hockey:settings-updated", { detail: { settings: savedSettings || {} }, })); // Mapping can use virtual *_RUS/*_ENG columns. Language is a global // Hockey setting, while live Agents may belong to other operator accounts. // Therefore refresh every active broadcast Agent, not only the admin's own. let mappingRefresh = null; if (previousLanguage !== nextLanguage) { try { mappingRefresh = await request("/api/hockey/admin/vmix-mapping/apply-all-active", { method: "POST" }); } catch (mappingError) { status.textContent = `${t("saved")} · vMix: ${mappingError.message}`; status.classList.add("error"); } } if (!status.classList.contains("error")) { if (mappingRefresh && Number(mappingRefresh.total_devices || 0) > 0) { status.textContent = `${t("saved")} · vMix ${Number(mappingRefresh.applied_devices || 0)}/${Number(mappingRefresh.total_devices || 0)}`; } else { status.textContent = t("saved"); } status.classList.remove("error"); } setTimeout(close, 120); loadNavigation().catch((error) => console.warn("[Hockey] Navigation refresh after settings save failed:", error)); } catch (error) { status.textContent = error.message; status.classList.add("error"); } }); } async function init() { await ensureAccountScopedRuntimeState(); ensureShell(); renderStaticLabels(); // Warm the tiny local settings endpoint while navigation is loading, so the // gear opens immediately instead of starting its first request on click. void loadSettingsCached().catch(() => {}); await loadNavigation(); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", init, { once: true }); } else { init(); } window.HockeyTournamentMenu = { open: () => setOpen(true), close: () => setOpen(false), reload: loadNavigation, getSelectedTournamentId: () => state.selectedId, getSelectedGameId: () => state.selectedGameId, selectTournament: openTournament, selectGame: openGame, reloadGames: () => loadGames({ autoSync: false }), }; function installGlobalTooltipPortal() { if (document.querySelector(".hockey-global-tooltip")) return; const tooltip = document.createElement("div"); tooltip.className = "hockey-global-tooltip"; document.body.appendChild(tooltip); let current = null; const hide = () => { current = null; tooltip.classList.remove("show"); tooltip.textContent = ""; }; const position = () => { if (!current) return; const rect = current.getBoundingClientRect(); const tipRect = tooltip.getBoundingClientRect(); let left = rect.left + rect.width / 2 - tipRect.width / 2; left = Math.max(8, Math.min(window.innerWidth - tipRect.width - 8, left)); let top = rect.top - tipRect.height - 10; if (top < 8) top = rect.bottom + 10; tooltip.style.left = `${left}px`; tooltip.style.top = `${Math.max(8, top)}px`; }; const show = (target) => { const text = String(target?.dataset?.tooltip || target?.getAttribute?.("aria-label") || "").trim(); if (!text) return; current = target; tooltip.textContent = text; tooltip.classList.add("show"); requestAnimationFrame(position); }; document.addEventListener("pointerover", (event) => { const target = event.target.closest?.("[data-tooltip]"); if (target) show(target); }); document.addEventListener("pointerout", (event) => { if (!current) return; const next = event.relatedTarget; if (next && current.contains(next)) return; hide(); }); document.addEventListener("focusin", (event) => { const target = event.target.closest?.("[data-tooltip]"); if (target) show(target); }); document.addEventListener("focusout", hide); window.addEventListener("scroll", hide, true); window.addEventListener("resize", hide); } installGlobalTooltipPortal(); })();