(() => { "use strict"; const runtimeStorage = window.HockeyMatchAccess?.storage || localStorage; const boot = window.UI_BUILDER_BOOT || {}; if (boot.mode !== "runtime") return; const state = { root: null, signature: "", timerId: "hockey_game_timer", dashboardId: "hockey_penalty_dashboard", interval: null, resizeObserver: null, control: null, controlGameId: "", controlLoading: false, controlFetchedAt: 0, controlContextKey: "", controlRefreshQueued: false, controlReadRevision: 0, periodEditing: false, }; const escapeHtml = (value) => String(value ?? "") .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); function formatDate(value, language) { const raw = String(value || "").trim(); if (!raw) return "Дата не указана"; const parsed = new Date(`${raw}T12:00:00`); if (Number.isNaN(parsed.getTime())) return raw; return parsed.toLocaleDateString(language === "en" ? "en-GB" : "ru-RU", { day: "2-digit", month: "short", year: "numeric", }); } function formatPenaltyTime(milliseconds) { const totalSeconds = Math.max(0, Math.ceil(Number(milliseconds || 0) / 1000)); const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; return `${minutes}:${String(seconds).padStart(2, "0")}`; } function discoverActionIds() { const components = window.UIBuilderRuntime?.getConfig?.()?.components || []; const timer = components.find((item) => item.action_id === "hockey_game_timer") || components.find((item) => item.type === "timer"); const dashboard = components.find((item) => item.type === "hockey_penalty_dashboard"); state.timerId = timer?.action_id || "hockey_game_timer"; state.dashboardId = dashboard?.action_id || "hockey_penalty_dashboard"; } function snapshot() { const data = window.UIBuilderRuntime?.getData?.() || {}; const game = data.hockey?.selected_game || null; const tournament = data.hockey?.selected_tournament || null; const language = data.hockey?.language?.display || runtimeStorage.getItem("hockey.language") || "ru"; const timer = window.UIBuilderRuntime?.getTimer?.(state.timerId) || null; const board = window.UIBuilderRuntime?.getHockeyPenaltyBoard?.(state.dashboardId) || {}; const penalties = (Array.isArray(board.penalties) ? board.penalties : []) .filter((event) => ( event && !event.finished && (event.player || (event.teamPenalty && ["home", "away"].includes(event.side))) && event.infraction && event.preset )) .sort((left, right) => { const sideOrder = { home: 0, away: 1 }; return (sideOrder[left.player?.side || left.side] ?? 2) - (sideOrder[right.player?.side || right.side] ?? 2) || Number(left.createdAt || 0) - Number(right.createdAt || 0); }); return { data, game, tournament, language, timer, penalties, control: state.control }; } async function controlRequest(path, options = {}) { const response = await fetch(`/api/hockey${path}`, { cache: "no-store", credentials: "same-origin", ...options, headers: { ...(options.body ? { "Content-Type": "application/json" } : {}), ...(options.headers || {}), }, }); let payload = {}; try { payload = await response.json(); } catch (_) {} if (!response.ok) { const detail = typeof payload.detail === "string" ? payload.detail : `HTTP ${response.status}`; throw new Error(detail); } return payload; } function controlContext() { const hockey = window.UIBuilderRuntime?.getData?.()?.hockey || {}; const gameId = String(hockey.selected_game?.external_id || hockey.selected_game?.id || "").trim(); const language = hockey.language?.display === "en" ? "en" : "ru"; const session = hockey.operator_session || hockey.session || {}; const token = String(session.token || session.channel_key || ""); return { gameId, language, key: JSON.stringify([gameId, language, token]) }; } async function loadControl(force = false) { const { gameId, language, key } = controlContext(); if (!gameId) { state.control = null; state.controlGameId = ""; state.controlContextKey = key; return; } if (state.controlContextKey !== key) { state.control = null; state.controlGameId = gameId; state.controlContextKey = key; state.controlFetchedAt = 0; force = true; } if (state.controlLoading) { if (force) state.controlRefreshQueued = true; return; } if (!force && Date.now() - state.controlFetchedAt < 1800) return; state.controlLoading = true; const readRevision = state.controlReadRevision; try { const previousPeriod = String(state.control?.current_period || ""); const payload = await controlRequest(`/games/${encodeURIComponent(gameId)}/control?language=${language}`); if (key !== state.controlContextKey || key !== controlContext().key) { state.controlRefreshQueued = true; return; } // A timer/period mutation can publish a newer snapshot while this read // is pending. Keep that snapshot until the next ordinary refresh. if (readRevision !== state.controlReadRevision) return; state.control = payload; state.controlFetchedAt = Date.now(); const nextPeriod = String(state.control?.current_period || ""); if (!window.HockeyMatchAccess?.canControl?.(gameId)) { window.dispatchEvent(new CustomEvent("hockey:control-snapshot", { detail: { game_id: gameId, control: state.control }, })); } else if (previousPeriod && nextPeriod && previousPeriod !== nextPeriod) { window.UIBuilderRuntime?.patchData?.({ hockey: { game_control: state.control } }); window.dispatchEvent(new CustomEvent("hockey:game-control-updated", { detail: { game_id: gameId, control: state.control, apply_timers: true }, })); } render(true); } catch (error) { console.error("Could not load hockey period state", error); } finally { state.controlLoading = false; if (state.controlRefreshQueued) { state.controlRefreshQueued = false; void loadControl(true); } } } async function updatePeriod(gameId, value, language) { if (!window.HockeyMatchAccess?.canControl?.(gameId)) return; try { state.control = await controlRequest(`/games/${encodeURIComponent(gameId)}/control/period`, { method: "PUT", body: JSON.stringify({ current_period: value, language }), }); state.controlFetchedAt = Date.now(); window.UIBuilderRuntime?.patchData?.({ hockey: { game_control: state.control } }); window.dispatchEvent(new CustomEvent("hockey:game-control-updated", { detail: { game_id: gameId, control: state.control, apply_timers: true }, })); render(true); } catch (error) { console.error("Could not update hockey period", error); window.alert(error.message || "Не удалось изменить период"); await loadControl(true); } } function timerStatus(timer, language) { if (!timer) return language === "en" ? "not configured" : "не настроен"; if (timer.finished) return language === "en" ? "finished" : "завершён"; if (timer.running) return language === "en" ? "running" : "идёт"; if (timer.paused) return language === "en" ? "paused" : "пауза"; return language === "en" ? "stopped" : "остановлен"; } function penaltyMarkup(event, language, teamNames = {}) { const side = event.player?.side || event.side || "neutral"; const teamPenalty = Boolean(event.teamPenalty); const number = String(event.player?.number || "").replace(/^#/, ""); const playerName = teamPenalty ? (teamNames[side] || (language === "en" ? "Team penalty" : "Командное удаление")) : (event.player?.name || (language === "en" ? "Player" : "Игрок")); const infraction = event.infraction?.label || event.infraction?.name || event.infraction?.id || ""; const accessibleLabel = [playerName, infraction, formatPenaltyTime(event.remainingMs)] .filter(Boolean) .join(" · "); return `
${teamPenalty ? (language === "en" ? "TEAM" : "КОМ") : number ? `#${escapeHtml(number)}` : "—"}
`; } function render(force = false) { if (!state.root || !window.UIBuilderRuntime) return; const periodSelect = state.root.querySelector("[data-hockey-current-period]"); const periodShell = periodSelect?.closest(".hockey-status-period"); const periodBusy = Boolean(state.periodEditing || (periodShell && ( periodShell.contains(document.activeElement) || periodShell.querySelector(".control-popover-open") ))); const value = snapshot(); const game = value.game; const language = value.language === "en" ? "en" : "ru"; const home = game?.home || {}; const away = game?.away || {}; const homeName = home.name || (language === "en" ? "Home team" : "Хозяева"); const awayName = away.name || (language === "en" ? "Away team" : "Гости"); const scoreHome = Number.isFinite(Number(home.score)) ? Number(home.score) : 0; const scoreAway = Number.isFinite(Number(away.score)) ? Number(away.score) : 0; const clock = value.timer?.formatted || "—:—"; const canEditClock = Boolean(value.timer && game && window.HockeyMatchAccess?.canControl?.(String(game.external_id || game.id || ""))); const dateText = formatDate(game?.date, language); const startTime = String(game?.time || "").trim(); const league = value.tournament?.league || value.tournament?.name || ""; const finishLabel = String(game?.score?.finish_label || "").trim() || (String(game?.score?.finish_type || "").toUpperCase() === "SO" ? (language === "en" ? "SO" : "Б") : String(game?.score?.finish_type || "").toUpperCase() === "OT" ? (language === "en" ? "OT" : "ОТ") : ""); const stageKey = String(value.tournament?.stage_key || "").toLowerCase(); const fallbackPeriodOptions = [ { value: "1", label: language === "en" ? "1st period" : "1 период" }, { value: "2", label: language === "en" ? "2nd period" : "2 период" }, { value: "3", label: language === "en" ? "3rd period" : "3 период" }, ...(stageKey === "playoff" ? [ { value: "ot1", label: language === "en" ? "Overtime 1" : "1 овертайм" }, { value: "ot2", label: language === "en" ? "Overtime 2" : "2 овертайм" }, ] : [ { value: "ot", label: language === "en" ? "Overtime" : "Овертайм" }, ...(stageKey === "regular" ? [{ value: "so", label: language === "en" ? "Shootout" : "Буллиты" }] : []), ]), { value: "finished", label: language === "en" ? "Finished" : "Матч завершён" }, ]; const periodOptions = Array.isArray(value.control?.period_options) ? value.control.period_options : fallbackPeriodOptions; const currentPeriod = String(value.control?.current_period || "1"); const strength = value.control?.strength || {}; const strengthLabel = String(strength.strength_label || ""); const strengthAdvantage = String(strength.advantage_side || ""); const strengthHomeLabel = String(strength.home_label || ""); const strengthAwayLabel = String(strength.away_label || ""); const strengthStateLabel = String(strength.state_label || ""); const signature = JSON.stringify({ game: game?.external_id || "", canControl: window.HockeyMatchAccess?.canControl?.(String(game?.external_id || game?.id || "")) === true, homeName, awayName, scoreHome, scoreAway, dateText, startTime, league, finishLabel, currentPeriod, periodOptions, strengthLabel, strengthAdvantage, strengthHomeLabel, strengthAwayLabel, strengthStateLabel, clock, timerRunning: Boolean(value.timer?.running), timerPaused: Boolean(value.timer?.paused), penalties: value.penalties.map((event) => [ event.id, event.player?.name, event.player?.number, event.player?.side || event.side, event.teamPenalty, event.infraction?.label, event.running, formatPenaltyTime(event.remainingMs), ]), language, }); if (!force && signature === state.signature) return; state.signature = signature; const penalties = value.penalties.length ? value.penalties.map((event) => penaltyMarkup(event, language, { home: homeName, away: awayName })).join("") : `
${language === "en" ? "No active penalties" : "Нет активных удалений"}
`; const advantageTeam = strengthAdvantage === "home" ? homeName : strengthAdvantage === "away" ? awayName : ""; const advantageText = strengthAdvantage === "home" ? strengthHomeLabel : strengthAdvantage === "away" ? strengthAwayLabel : strengthStateLabel; const strengthMarkup = game && strengthLabel ? `
${language === "en" ? "STRENGTH" : "СОСТАВЫ"} ${escapeHtml(strengthLabel)} ${escapeHtml(advantageText ? `${advantageText}${advantageTeam ? ` · ${advantageTeam}` : ""}` : (language === "en" ? "EVEN" : "РАВНЫЕ"))}
` : ""; state.root.classList.toggle("has-game", Boolean(game)); const markup = `
${language === "en" ? "HOME" : "ХОЗЯЕВА"} ${escapeHtml(homeName)}
${game ? `${scoreHome} : ${scoreAway}` : "— : —"} ${finishLabel ? `${escapeHtml(finishLabel)}` : ""}
${language === "en" ? "AWAY" : "ГОСТИ"} ${escapeHtml(awayName)}
${league ? `${escapeHtml(league)}` : ""} ${escapeHtml(dateText)}
${language === "en" ? "MAIN TIMER" : "ОСНОВНОЙ ТАЙМЕР"} ${escapeHtml(clock)} ${escapeHtml(timerStatus(value.timer, language))}
${strengthMarkup} ${penalties}
`; const periodSignature = JSON.stringify([game?.external_id || "", language, currentPeriod, periodOptions, Boolean(game && window.HockeyMatchAccess?.canControl?.(String(game.external_id || game.id || "")))]); const fragment = document.createElement("template"); fragment.innerHTML = markup; for (const fresh of Array.from(fragment.content.children)) { const current = state.root.querySelector(`.${fresh.classList[0]}`); if (fresh.classList.contains("hockey-status-period")) { const sameGame = state.root.dataset.periodGameId === String(game?.external_id || ""); if (current && sameGame && (periodBusy || state.root.dataset.periodSignature === periodSignature)) { if (state.root.dataset.periodSignature !== periodSignature) state.signature = ""; continue; } state.periodEditing = false; state.root.dataset.periodSignature = periodSignature; state.root.dataset.periodGameId = String(game?.external_id || ""); } else if (current && fresh.classList.contains("hockey-status-clock")) { // Keep the click target alive between the two clicks, including when // a running clock ticks or live match data refreshes in between. current.className = fresh.className; current.title = fresh.title; for (const selector of ["span", "strong", "small"]) { const target = current.querySelector(selector); const text = fresh.querySelector(selector).textContent; if (target.textContent !== text) target.textContent = text; } continue; } else if (current?.outerHTML === fresh.outerHTML) continue; if (current) current.replaceWith(fresh); else state.root.appendChild(fresh); } const currentPeriodSelect = state.root.querySelector("[data-hockey-current-period]"); if (!currentPeriodSelect || currentPeriodSelect.dataset.periodBound === "1") return; currentPeriodSelect.dataset.periodBound = "1"; window.dispatchEvent(new CustomEvent("ui-builder:controls-rendered", { detail: { root: state.root } })); currentPeriodSelect?.addEventListener("pointerdown", () => { state.periodEditing = true; }); currentPeriodSelect?.addEventListener("focus", () => { state.periodEditing = true; }); currentPeriodSelect?.addEventListener("change", (event) => { if (!game?.external_id) return; updatePeriod(String(game.external_id), event.target.value, language); }); currentPeriodSelect?.addEventListener("blur", () => { state.periodEditing = false; window.setTimeout(() => render(true), 0); }); } function mount() { if (state.root) return; const topbar = document.querySelector(".runtime-topbar"); if (!topbar) return; const root = document.createElement("section"); root.id = "hockeyMatchStatusHeader"; root.className = "hockey-match-status"; root.setAttribute("aria-label", "Краткое состояние матча"); root.tabIndex = 0; root.title = "Нажмите, чтобы выбрать турнир или матч"; root.addEventListener("click", (event) => { if (!event.target.closest(".hockey-status-match")) return; document.querySelector(".runtime-actions .hockey-selected-chip")?.click(); }); root.addEventListener("dblclick", (event) => { if (!event.target.closest(".hockey-status-clock")) return; event.preventDefault(); event.stopPropagation(); const value = snapshot(); const gameId = String(value.game?.external_id || value.game?.id || ""); if (!value.timer || !gameId || !window.HockeyMatchAccess?.canControl?.(gameId)) return; window.UIBuilderRuntime?.openTimersEditor?.(state.timerId); }); root.addEventListener("keydown", (event) => { if (event.target !== root && !event.target.closest(".hockey-status-match")) return; if (event.key !== "Enter" && event.key !== " ") return; event.preventDefault(); document.querySelector(".runtime-actions .hockey-selected-chip")?.click(); }); topbar.appendChild(root); state.root = root; discoverActionIds(); render(true); if (typeof ResizeObserver === "function") { state.resizeObserver = new ResizeObserver(() => { window.UIBuilderRuntime?.refreshLayout?.(); }); state.resizeObserver.observe(topbar); } requestAnimationFrame(() => window.UIBuilderRuntime?.refreshLayout?.()); state.interval = window.setInterval(() => { if (!document.hidden) { render(); loadControl(false); } }, 250); } window.addEventListener("hockey:game-selected", () => { loadControl(); render(true); }); window.addEventListener("hockey:settings-updated", () => { loadControl(true); render(true); }); window.addEventListener("hockey:game-control-updated", (event) => { const payload = event.detail?.control; const gameId = String(event.detail?.game_id || payload?.game_id || ""); const context = controlContext(); if (!payload || (gameId && context.gameId !== gameId)) return; state.controlReadRevision += 1; state.controlGameId = context.gameId; state.controlContextKey = context.key; state.control = payload; state.controlFetchedAt = Date.now(); render(true); }); window.addEventListener("ui-builder:data-patched", () => render(true)); document.addEventListener("visibilitychange", () => { if (!document.hidden) render(true); }); if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", mount, { once: true }); } else { mount(); } loadControl(true); })();