(() => {
"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,
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();
}
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") {
throw new Error("Превышено время ожидания ответа Stat2TV");
}
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 = `
STAT2TV / HOCKEY
${escapeHtml(t("loadingMatchTitle"))}
${escapeHtml(t("loadingMatchDetails"))}
${escapeHtml(t("loadingMatchHint"))}
${state.isAdmin ? `
${escapeHtml(t("notConfigured"))}
` : ""}
${escapeHtml(t("today"))}
${escapeHtml(t("all"))}
⌕
${escapeHtml(t("league"))}
${escapeHtml(t("season"))}
${escapeHtml(t("stage"))}
${boot.mode === "editor" ? `
` : ""}
‹
${escapeHtml(t("gamesForDate"))}
${escapeHtml(t("today"))}
›
${state.isAdmin ? `
` : ""}
`;
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 = `${escapeHtml(t("allValues"))} `;
const league = state.root.querySelector('[data-filter="league"]');
league.innerHTML = allOption + filters.leagues.map((item) => `
${escapeHtml(item.label)}
`).join("");
league.value = state.league;
const season = state.root.querySelector('[data-filter="season"]');
season.innerHTML = allOption + filters.seasons.map((item) => `
${escapeHtml(item)}
`).join("");
season.value = state.season;
const stage = state.root.querySelector('[data-filter="stage"]');
stage.innerHTML = allOption + filters.stages.map((item) => `
${escapeHtml(item.label)}
`).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 `
${escapeHtml(item.stage)}
${escapeHtml(item.name || item.full_name)}
${escapeHtml(item.season || "—")}
${escapeHtml(dates)}
›
`;
}
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 || "")}
${escapeHtml(t("changeTournament"))}
`;
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
? `${escapeHtml(t("connections"))} `
: "";
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 `
${escapeHtml(game.time || "—")}
${game.game_number ? `#${escapeHtml(game.game_number)}` : ""}
${round ? ` · ${escapeHtml(round)}` : ""}
${status ? `${escapeHtml(status)} ` : ""}
${escapeHtml(homeName)}
${showScore ? `${Number(game.home?.score || 0)} ` : `— `}
${escapeHtml(awayName)}
${showScore ? `${Number(game.away?.score || 0)} ` : `— `}
${escapeHtml(game.arena || "")}
${series ? `${escapeHtml(t("series"))}: ${escapeHtml(series)} ` : ""}
${selected ? "✓" : "›"}
`;
}
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) {
try {
const loaded = await request(
`/api/hockey/games/${encodeURIComponent(id)}/details/sync`
+ `?tournament_id=${encodeURIComponent(tournamentId)}`
+ `&language=${encodeURIComponent(state.language)}`,
{ method: "POST", timeoutMs: 16000 }
);
if (
generation !== state.gameOpenGeneration
|| String(state.selectedGameId || "") !== String(id)
) return;
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 || []);
}
} 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);
}
}
async function openGame(id, { manual = false } = {}) {
if (state.gameOpenBusy) return;
state.lastAttemptedGameId = String(id || "");
state.lastGameOpenError = "";
state.gameOpenBusy = true;
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 {
try {
const cached = await request(
`/api/hockey/games/${encodeURIComponent(id)}/details?language=${encodeURIComponent(state.language)}`,
{ timeoutMs: 5000 }
);
state.selectedGame = cached.game;
state.selectedMatchDetails = cached.details || null;
} catch (_) {
try {
state.selectedGame = await request(
`/api/hockey/games/${encodeURIComponent(id)}?language=${encodeURIComponent(state.language)}`,
{ timeoutMs: 5000 }
);
state.selectedMatchDetails = null;
} catch (baseError) {
if (!scheduleGame) throw baseError;
state.selectedGame = scheduleGame;
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);
}
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;
}
}
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);
}
// Publish the selected match immediately. Do not wait for lineups, shots,
// standings or any remote Stat2TV dependency before the operator can work.
applyGameData(session);
renderSelectedChip();
startSelectedGamePolling();
setOpen(false);
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 = ""; });
// Enrich in the background. If a particular match has no full JSON card,
// the already-open schedule match remains active and usable.
void enrichOpenedGame(String(id), state.selectedId, generation);
} 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 = `
${escapeHtml(t("checkAgain"))}
${escapeHtml(t("deepMatchTest"))}
`;
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 = `
${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;
}
}
async function openSettings() {
try {
state.settings = await request("/api/hockey/settings");
} catch (error) {
notify(error.message, true);
return;
}
const value = state.settings;
const modal = document.createElement("div");
modal.className = "hockey-settings-modal";
modal.innerHTML = `
`;
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")),
}),
});
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");
}
await loadNavigation();
setTimeout(close, 650);
} catch (error) {
status.textContent = error.message;
status.classList.add("error");
}
});
}
async function init() {
await ensureAccountScopedRuntimeState();
ensureShell();
renderStaticLabels();
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();
})();