(() => {
"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);
})();