Files
hockey_new/hockey_data/static/tournament-menu.js
2026-08-24 17:59:33 +03:00

2399 lines
100 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

(() => {
"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("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
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") {
// BUILD109: this helper is used for local DB/session/Agent endpoints too.
// Calling every HTTP timeout a Stat2TV timeout hides the real subsystem.
throw new Error("Превышено время ожидания ответа сервера");
}
throw error;
} finally {
clearTimeout(timeout);
}
}
function notify(message, error = false) {
if (!error && window.HOCKEY_RUNTIME_TOASTS_ENABLED === false) return;
let toast = document.querySelector(".hockey-module-toast");
if (!toast) {
toast = document.createElement("div");
toast.className = "hockey-module-toast";
document.body.appendChild(toast);
}
toast.textContent = message;
toast.classList.toggle("error", error);
toast.classList.add("show");
clearTimeout(toast._hideTimer);
toast._hideTimer = setTimeout(() => toast.classList.remove("show"), 3200);
}
function gameById(id) {
return (state.games?.items || []).find(
(item) => String(item?.external_id || item?.id || "") === String(id || "")
) || null;
}
function matchLabelById(id) {
const game = gameById(id);
if (!game) return "";
const home = game.home?.name || game.home_name || game.team1 || "";
const away = game.away?.name || game.away_name || game.team2 || "";
return [home, away].filter(Boolean).join(" — ");
}
function setMatchLoading(visible, { game = "", stage = "" } = {}) {
const overlay = state.root?.querySelector("[data-match-loading]");
if (!overlay) return;
const titleNode = overlay.querySelector("[data-match-loading-title]");
const gameNode = overlay.querySelector("[data-match-loading-game]");
const stageNode = overlay.querySelector("[data-match-loading-stage]");
const hintNode = overlay.querySelector("[data-match-loading-hint]");
if (titleNode) titleNode.textContent = t("loadingMatchTitle");
if (gameNode) {
gameNode.textContent = game || "";
gameNode.classList.toggle("hidden", !game);
}
if (stageNode) stageNode.textContent = stage || t("loadingMatchDetails");
if (hintNode) hintNode.textContent = t("loadingMatchHint");
overlay.classList.toggle("show", Boolean(visible));
overlay.setAttribute("aria-hidden", visible ? "false" : "true");
document.documentElement.classList.toggle("hockey-match-loading-active", Boolean(visible));
}
function updateMatchLoading(stage) {
const overlay = state.root?.querySelector("[data-match-loading]");
const stageNode = overlay?.querySelector("[data-match-loading-stage]");
if (stageNode) stageNode.textContent = stage || t("loadingMatchDetails");
}
function toolbarHost() {
return boot.mode === "editor"
? document.querySelector(".topbar .top-actions")
: document.querySelector(".runtime-actions");
}
function ensureShell() {
if (state.root) return;
const toolbar = toolbarHost();
if (toolbar) {
const chip = document.createElement("button");
chip.type = "button";
chip.className = "hockey-selected-chip placeholder";
chip.addEventListener("click", () => {
state.view = state.selectedTournament ? "games" : "tournaments";
setOpen(true);
renderView();
});
toolbar.prepend(chip);
state.selectedChip = chip;
}
const root = document.createElement("div");
root.className = "hockey-navigation-root";
root.innerHTML = `
<div class="hockey-navigation-backdrop"></div>
<div class="hockey-match-loading" data-match-loading aria-hidden="true">
<div class="hockey-match-loading-card" role="status" aria-live="polite" aria-atomic="true">
<div class="hockey-match-loading-spinner" aria-hidden="true"><i></i></div>
<small>STAT2TV / HOCKEY</small>
<strong data-match-loading-title>${escapeHtml(t("loadingMatchTitle"))}</strong>
<span class="hidden" data-match-loading-game></span>
<p data-match-loading-stage>${escapeHtml(t("loadingMatchDetails"))}</p>
<div class="hockey-match-loading-progress" aria-hidden="true"><i></i></div>
<em data-match-loading-hint>${escapeHtml(t("loadingMatchHint"))}</em>
</div>
</div>
<aside class="hockey-navigation-drawer" aria-label="${escapeHtml(t("title"))}">
<header class="hockey-nav-head">
<button type="button" class="hockey-nav-back hidden" data-back data-tooltip="${escapeHtml(t("back"))}">← <span>${escapeHtml(t("tournaments"))}</span></button>
<div class="hockey-nav-title">
<small>STAT2TV / HOCKEY</small>
<strong data-nav-title>${escapeHtml(t("tournaments"))}</strong>
</div>
<div class="hockey-nav-head-actions">
<div class="hockey-language-switch">
<button type="button" data-language="ru">RU</button>
<button type="button" data-language="en">EN</button>
</div>
${state.isAdmin ? `
<button type="button" class="hockey-nav-icon" data-open-connection-diagnostics data-tooltip="${escapeHtml(t("connections"))}">⌁</button>
<button type="button" class="hockey-nav-icon" data-open-settings data-tooltip="${escapeHtml(t("settings"))}">⚙</button>
` : ""}
${boot.mode !== "editor" ? `
<button type="button" class="hockey-nav-icon" data-logout data-tooltip="Выйти">⇥</button>
` : ""}
<button type="button" class="hockey-nav-icon" data-close-nav data-tooltip="${escapeHtml(t("close"))}">×</button>
</div>
</header>
${state.isAdmin ? `
<button type="button" class="hockey-connection-bar" data-connection-bar data-open-connection-diagnostics>
<span></span>
<strong>${escapeHtml(t("notConfigured"))}</strong>
</button>
` : ""}
<section class="hockey-tournament-view" data-view="tournaments">
<div class="hockey-nav-tabs">
<button type="button" data-nav-mode="today">${escapeHtml(t("today"))}</button>
<button type="button" data-nav-mode="all">${escapeHtml(t("all"))}</button>
</div>
<div class="hockey-nav-search">
<span>⌕</span>
<input type="search" data-nav-search placeholder="${escapeHtml(t("search"))}">
</div>
<div class="hockey-nav-filters">
<label>
<span>${escapeHtml(t("league"))}</span>
<select data-filter="league"></select>
</label>
<label>
<span>${escapeHtml(t("season"))}</span>
<select data-filter="season"></select>
</label>
<label>
<span>${escapeHtml(t("stage"))}</span>
<select data-filter="stage"></select>
</label>
</div>
<form class="hockey-manual-game hockey-manual-game-global" data-manual-game-form>
<div>
<strong>${escapeHtml(t("manualMatchTitle"))}</strong>
<small>${escapeHtml(t("manualMatchHint"))}</small>
</div>
<label>
<input type="text" inputmode="numeric" autocomplete="off" data-manual-game-id placeholder="${escapeHtml(t("manualMatchPlaceholder"))}">
<button type="submit">${escapeHtml(t("manualMatchButton"))}</button>
</label>
</form>
<div class="hockey-nav-summary" data-nav-summary></div>
<div class="hockey-nav-list" data-nav-list></div>
${boot.mode === "editor" ? `
<footer class="hockey-nav-footer">
<button type="button" class="btn" data-seed-import>${escapeHtml(t("importSeed"))}</button>
<button type="button" class="btn btn-accent" data-sync>${escapeHtml(t("sync"))}</button>
</footer>
` : ""}
</section>
<section class="hockey-games-view hidden" data-view="games">
<div class="hockey-selected-tournament" data-selected-tournament></div>
<div class="hockey-game-date-nav">
<button type="button" data-date-shift="-1" data-tooltip="${escapeHtml(t("datePrevious"))}"></button>
<label>
<span>${escapeHtml(t("gamesForDate"))}</span>
<input type="date" data-game-date value="${escapeHtml(state.gameDate)}">
</label>
<button type="button" data-date-today data-tooltip="${escapeHtml(t("dateToday"))}">${escapeHtml(t("today"))}</button>
<button type="button" data-date-shift="1" data-tooltip="${escapeHtml(t("dateNext"))}"></button>
</div>
<div class="hockey-games-summary" data-games-summary></div>
<form class="hockey-manual-game" data-manual-game-form>
<div>
<strong>${escapeHtml(t("manualMatchTitle"))}</strong>
<small>${escapeHtml(t("manualMatchHint"))}</small>
</div>
<label>
<input type="text" inputmode="numeric" autocomplete="off" data-manual-game-id placeholder="${escapeHtml(t("manualMatchPlaceholder"))}">
<button type="submit">${escapeHtml(t("manualMatchButton"))}</button>
</label>
</form>
<div class="hockey-games-list" data-games-list></div>
${state.isAdmin ? `
<footer class="hockey-games-footer">
<button type="button" class="btn" data-games-diagnostics>${escapeHtml(t("diagnostics"))}</button>
<button type="button" class="btn" data-games-rediscover>${escapeHtml(t("rediscover"))}</button>
</footer>
` : ""}
</section>
</aside>
`;
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 }, () => `
<div class="hockey-nav-skeleton">
<i></i><div><b></b><span></span></div>
</div>
`).join("");
}
function renderTournamentError(message) {
const list = state.root?.querySelector("[data-nav-list]");
if (!list) return;
list.innerHTML = `
<div class="hockey-nav-empty error">
<strong>Stat2TV</strong>
<p>${escapeHtml(message)}</p>
</div>
`;
}
function renderFilters() {
const filters = state.navigation?.filters;
if (!filters) return;
const allOption = `<option value="">${escapeHtml(t("allValues"))}</option>`;
const league = state.root.querySelector('[data-filter="league"]');
league.innerHTML = allOption + filters.leagues.map((item) => `
<option value="${escapeHtml(item.value)}">${escapeHtml(item.label)}</option>
`).join("");
league.value = state.league;
const season = state.root.querySelector('[data-filter="season"]');
season.innerHTML = allOption + filters.seasons.map((item) => `
<option value="${escapeHtml(item)}">${escapeHtml(item)}</option>
`).join("");
season.value = state.season;
const stage = state.root.querySelector('[data-filter="stage"]');
stage.innerHTML = allOption + filters.stages.map((item) => `
<option value="${escapeHtml(item.value)}">${escapeHtml(item.label)}</option>
`).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 = `
<span>${escapeHtml(t("offSeason"))}</span>
<strong>${escapeHtml(t("fallback"))}: ${escapeHtml(meta.latest_season || "—")}</strong>
`;
} else {
summary.innerHTML = `
<span>${escapeHtml(state.mode === "today" ? t("today") : t("all"))}</span>
<strong>${state.mode === "today" ? meta.today_count : meta.shown}</strong>
`;
}
const items = tournamentListForMode();
const list = state.root.querySelector("[data-nav-list]");
if (!items.length) {
list.innerHTML = `
<div class="hockey-nav-empty">
<strong>${escapeHtml(t("noItems"))}</strong>
<p>${escapeHtml(state.mode === "today" ? t("noToday") : "")}</p>
</div>
`;
return;
}
list.innerHTML = groupTournaments(items).map((group) => `
<section class="hockey-league-group">
<header>
<span>${escapeHtml(group.level.toUpperCase())}</span>
<strong>${escapeHtml(group.label)}</strong>
<i>${group.items.length}</i>
</header>
<div>${group.items.map(tournamentCard).join("")}</div>
</section>
`).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 `
<button type="button"
class="hockey-tournament-card ${selected ? "selected" : ""}"
data-tournament-id="${escapeHtml(item.id)}">
<span class="hockey-tournament-stage">${escapeHtml(item.stage)}</span>
<strong>${escapeHtml(item.name || item.full_name)}</strong>
<small>
<b>${escapeHtml(item.season || "—")}</b>
<span>${escapeHtml(dates)}</span>
</small>
<i></i>
</button>
`;
}
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 = `
<div>
<span>${escapeHtml(item.league)} · ${escapeHtml(item.season || "")}</span>
<strong>${escapeHtml(item.name || item.full_name || "")}</strong>
<small>${escapeHtml(item.stage || "")}</small>
</div>
<button type="button" data-change-tournament>${escapeHtml(t("changeTournament"))}</button>
`;
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 = `<span>${escapeHtml(message)}</span><i class="hockey-spinner"></i>`;
if (list) {
list.innerHTML = Array.from({ length: 4 }, () => `
<div class="hockey-game-skeleton">
<span></span><b></b><b></b><i></i>
</div>
`).join("");
}
}
function renderGamesError(message) {
const list = state.root?.querySelector("[data-games-list]");
const summary = state.root?.querySelector("[data-games-summary]");
if (summary) summary.innerHTML = `<span>${escapeHtml(t("syncFailed"))}</span>`;
if (list) {
list.innerHTML = `
<div class="hockey-games-empty error">
<strong>${escapeHtml(t("syncFailed"))}</strong>
<p>${escapeHtml(message)}</p>
<small>${escapeHtml(state.isAdmin ? `${t("diagnostics")} · ${t("adminOnly")}` : "")}</small>
</div>
`;
}
}
function matchOpenErrorMarkup() {
if (!state.lastGameOpenError) return "";
const diagnosticButton = state.isAdmin
? `<button type="button" class="btn" data-match-error-diagnostics>${escapeHtml(t("connections"))}</button>`
: "";
return `
<div class="hockey-games-empty error hockey-match-open-error">
<strong>${escapeHtml(state.language === "en" ? "Failed to open game" : "Не удалось открыть матч")}</strong>
<p>${escapeHtml(state.lastGameOpenError)}</p>
${diagnosticButton}
</div>
`;
}
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 = `
<div>
<span>${escapeHtml(formatDate(state.gameDate))}</span>
<strong>${items.length}</strong>
</div>
<small class="${meta.endpoint_template ? "found" : ""}">
${escapeHtml(meta.endpoint_template ? t("endpointFound") : t("endpointNotFound"))}
</small>
`;
if (!items.length) {
list.innerHTML = `${matchOpenErrorMarkup()}
<div class="hockey-games-empty">
<strong>${escapeHtml(meta.last_synced_at ? t("noGames") : t("noGamesLoaded"))}</strong>
<p>${escapeHtml(formatDate(state.gameDate))}</p>
<small>${escapeHtml(
meta.schedule_loaded
? t("scheduleCached")
: meta.credentials_configured
? t("loadingGames")
: state.isAdmin
? t("notConfigured")
: t("noGamesLoaded")
)}</small>
</div>
`;
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 `
<button type="button"
class="hockey-game-card ${selected ? "selected" : ""} ${game.status === "live" ? "live" : ""} ${game.is_finished ? "finished" : ""}"
data-game-id="${escapeHtml(game.external_id)}">
<header>
<span>${escapeHtml(game.time || "—")}</span>
<small>
${game.game_number ? `#${escapeHtml(game.game_number)}` : ""}
${round ? ` · ${escapeHtml(round)}` : ""}
</small>
${status ? `<i>${escapeHtml(status)}</i>` : ""}
</header>
<div class="hockey-game-team">
<strong>${escapeHtml(homeName)}</strong>
${showScore ? `<b>${Number(game.home?.score || 0)}</b>` : `<b>—</b>`}
</div>
<div class="hockey-game-team">
<strong>${escapeHtml(awayName)}</strong>
${showScore ? `<b>${Number(game.away?.score || 0)}</b>` : `<b>—</b>`}
</div>
<footer>
<span>${escapeHtml(game.arena || "")}</span>
${series ? `<em>${escapeHtml(t("series"))}: ${escapeHtml(series)}</em>` : ""}
<i>${selected ? "✓" : ""}</i>
</footer>
</button>
`;
}
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 if (scheduleGame) {
// BUILD109: the card the operator clicked is already a complete local
// schedule snapshot. Opening the workspace must be immediate; cached
// details/full match data are optional enrichment and are loaded below.
state.selectedGame = scheduleGame;
state.selectedMatchDetails = null;
} else {
// Defensive fallback for a stale DOM/list state. This path is local DB
// only and is not used for the normal visible match-card click.
state.selectedGame = await request(
`/api/hockey/games/${encodeURIComponent(id)}?language=${encodeURIComponent(state.language)}`,
{ timeoutMs: 5000 }
);
state.selectedMatchDetails = null;
}
const actualTournamentId = String(
state.selectedGame?.tournament_external_id || state.selectedId || ""
).trim();
if (!actualTournamentId) throw new Error("Не удалось определить турнир выбранного матча");
if (actualTournamentId !== String(state.selectedId || "")) {
state.selectedId = actualTournamentId;
localStorage.setItem(storage.tournament, state.selectedId);
try {
state.selectedTournament = await request(
`/api/hockey/tournaments/${encodeURIComponent(state.selectedId)}?language=${encodeURIComponent(state.language)}`,
{ timeoutMs: 5000 }
);
} catch (_) {
state.selectedTournament = state.selectedTournament || null;
}
state.standings = null;
state.tournamentStatistics = null;
state.games = null;
state.teamSchedule = null;
state.teamScheduleTournamentId = "";
applyTournamentData();
}
if (manual && state.selectedGame?.tournament_external_id) {
state.selectedId = String(state.selectedGame.tournament_external_id);
localStorage.setItem(storage.tournament, state.selectedId);
}
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 = `
<span>
${finished ? "✓" : "<i></i>"}
${escapeHtml(state.selectedGame.time || t("matches"))}
</span>
<strong>${escapeHtml(state.selectedGame.home.name)}${escapeHtml(state.selectedGame.away.name)}</strong>
`;
return;
}
if (state.selectedTournament) {
state.selectedChip.dataset.tooltip = t("selectedTournament");
state.selectedChip.innerHTML = `
<span>${escapeHtml(state.selectedTournament.league)}</span>
<strong>${escapeHtml(state.selectedTournament.name)}</strong>
`;
return;
}
state.selectedChip.classList.add("placeholder");
state.selectedChip.dataset.tooltip = t("chooseTournament");
state.selectedChip.innerHTML = `
<span>STAT2TV</span>
<strong>${escapeHtml(t("chooseTournament"))}</strong>
`;
}
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 `<span class="hockey-diagnostic-status ${ok ? "success" : "error"}">${ok ? "● OK" : "● ERROR"}</span>`;
}
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 = `
<div class="hockey-settings-backdrop"></div>
<div class="hockey-diagnostics-card hockey-connection-diagnostics-card">
<header>
<div><small>ADMIN / HEALTH</small><strong>${escapeHtml(t("connections"))}</strong></div>
<button type="button" data-close>×</button>
</header>
<div class="hockey-diagnostics-content" data-connection-diagnostics-content>
<div class="hockey-games-empty"><strong>…</strong></div>
</div>
<footer class="hockey-connection-diagnostics-actions">
<button type="button" class="btn" data-refresh>${escapeHtml(t("checkAgain"))}</button>
<button type="button" class="btn btn-accent" data-deep ${gameId && tournamentId ? "" : "disabled"}>${escapeHtml(t("deepMatchTest"))}</button>
</footer>
</div>
`;
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 = `<div class="hockey-games-empty"><strong>${escapeHtml(deep ? t("deepMatchTest") : t("checkAgain"))}…</strong></div>`;
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 = `
<div class="hockey-diagnostic-summary-grid">
<div class="hockey-diagnostic-summary ${selection.ok ? "success" : "error"}">
<small>${escapeHtml(t("matchSelectionReady"))}</small>
<strong>${selection.ok ? "OK" : "ERROR"}</strong>
<span>${escapeHtml(state.language === "en" ? "Depends on PostgreSQL" : "Зависит от PostgreSQL")}</span>
</div>
<div class="hockey-diagnostic-summary ${remote.ok ? "success" : "error"}">
<small>${escapeHtml(t("remoteSyncReady"))}</small>
<strong>${remote.ok ? "OK" : "ERROR"}</strong>
<span>${escapeHtml(state.language === "en" ? "PostgreSQL + Stat2TV" : "PostgreSQL + Stat2TV")}</span>
</div>
</div>
<section class="hockey-diagnostic-section">
<header><strong>${escapeHtml(t("database"))}</strong>${diagnosticStatus(Boolean(database.ok))}</header>
<dl>
<dt>Latency</dt><dd>${escapeHtml(database.latency_ms ?? "—")} ms</dd>
<dt>Host</dt><dd>${escapeHtml(database.target?.host || "—")}</dd>
<dt>Database</dt><dd>${escapeHtml(database.target?.database || "—")}</dd>
<dt>Error</dt><dd>${escapeHtml(database.error || "—")}</dd>
</dl>
</section>
<section class="hockey-diagnostic-section">
<header><strong>${escapeHtml(t("stat2tv"))}</strong>${diagnosticStatus(Boolean(stat2tv.ok))}</header>
<dl>
<dt>Configured</dt><dd>${settings.credentials_configured ? "YES" : "NO"}</dd>
<dt>Credentials source</dt><dd>${escapeHtml(settings.credentials_source || "missing")}</dd>
<dt>Login</dt><dd>${escapeHtml(settings.username_masked || "—")}</dd>
<dt>Base URL</dt><dd>${escapeHtml(settings.base_url || "—")}</dd>
<dt>HTTP</dt><dd>${escapeHtml(stat2tv.status_code ?? "—")}</dd>
<dt>Items</dt><dd>${escapeHtml(stat2tv.items_found ?? "—")}</dd>
<dt>Latency</dt><dd>${escapeHtml(stat2tv.latency_ms ?? "—")} ms</dd>
<dt>Error</dt><dd>${escapeHtml(diagnosticMessage(stat2tv))}</dd>
</dl>
</section>
<section class="hockey-diagnostic-section">
<header><strong>${escapeHtml(t("agentVmix"))}</strong>${diagnosticStatus(Boolean(agents.ok))}</header>
<p class="hockey-diagnostic-note">${escapeHtml(t("optionalConnection"))}</p>
<dl>
<dt>Agents online</dt><dd>${escapeHtml(agents.online ?? 0)}</dd>
<dt>vMix connected</dt><dd>${escapeHtml(agents.vmix_connected ?? 0)}</dd>
<dt>Error</dt><dd>${escapeHtml(agents.error || "—")}</dd>
</dl>
${agentDevices.length ? `<div class="hockey-diagnostic-devices">${agentDevices.map((item) => `
<div><strong>${escapeHtml(item.name || item.device_id || "Agent")}</strong><span>${item.online ? "ONLINE" : "OFFLINE"} · vMix ${item.vmix_connected ? "OK" : "OFF"}${item.owner ? ` · ${escapeHtml(item.owner)}` : ""}</span></div>
`).join("")}</div>` : ""}
</section>
${tournament ? `<section class="hockey-diagnostic-section">
<header><strong>${escapeHtml(state.language === "en" ? "Tournament" : "Турнир")} ${escapeHtml(tournament.tournament_id || "")}</strong>${diagnosticStatus(Boolean(tournament.ok))}</header>
<dl><dt>Local</dt><dd>${tournament.local_found ? "FOUND" : "NOT FOUND"}</dd></dl>
</section>` : ""}
${match ? `<section class="hockey-diagnostic-section">
<header><strong>${escapeHtml(t("selectedMatchCheck"))} ${escapeHtml(match.game_id || "")}</strong>${diagnosticStatus(Boolean(match.ok))}</header>
<dl>
<dt>Local</dt><dd>${match.local_found ? "FOUND" : "NOT FOUND"}</dd>
<dt>Deep</dt><dd>${match.deep_requested ? "YES" : "NO"}</dd>
<dt>Details</dt><dd>${match.details_loaded ? "LOADED" : (match.deep_requested ? "NOT LOADED" : "—")}</dd>
<dt>Degraded</dt><dd>${match.degraded ? "YES" : "NO"}</dd>
<dt>Latency</dt><dd>${escapeHtml(match.latency_ms ?? "—")} ms</dd>
<dt>Error</dt><dd>${escapeHtml(match.error || "—")}</dd>
</dl>
${warningItems.length ? `<div class="hockey-diagnostic-warnings">${warningItems.map((item) => `<p>${escapeHtml(item)}</p>`).join("")}</div>` : ""}
</section>` : ""}
${state.lastGameOpenError ? `<section class="hockey-diagnostic-section error"><header><strong>${escapeHtml(state.language === "en" ? "Last open error" : "Последняя ошибка открытия")}</strong></header><p>${escapeHtml(state.lastGameOpenError)}</p></section>` : ""}
`;
} catch (error) {
if (content) content.innerHTML = `<div class="hockey-games-empty error"><strong>${escapeHtml(t("diagnostics"))}</strong><p>${escapeHtml(error.message)}</p></div>`;
}
}
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 = `
<div class="hockey-settings-backdrop"></div>
<div class="hockey-diagnostics-card">
<header>
<div><small>STAT2TV</small><strong>${escapeHtml(t("diagnostics"))}</strong></div>
<button type="button" data-close>×</button>
</header>
<div class="hockey-diagnostics-content">
${latest ? `
<dl>
<dt>Status</dt><dd class="${latest.status === "success" ? "success" : "error"}">${escapeHtml(latest.status)}</dd>
<dt>Template</dt><dd>${escapeHtml(latest.template || "—")}</dd>
<dt>Endpoint</dt><dd>${escapeHtml(latest.endpoint || "—")}</dd>
<dt>HTTP</dt><dd>${escapeHtml(latest.status_code || "—")}</dd>
<dt>XML root</dt><dd>${escapeHtml(latest.root_tag || "—")}</dd>
<dt>Item tag</dt><dd>${escapeHtml(latest.item_tag || "—")}</dd>
<dt>Items</dt><dd>${escapeHtml(latest.item_count || 0)}</dd>
<dt>Snapshot</dt><dd>${escapeHtml(latest.snapshot_path || "—")}</dd>
<dt>Error</dt><dd>${escapeHtml(latest.error || "—")}</dd>
</dl>
<pre>${escapeHtml(JSON.stringify(latest.diagnostics || {}, null, 2))}</pre>
` : `<div class="hockey-games-empty"><strong>${escapeHtml(t("endpointNotFound"))}</strong></div>`}
</div>
</div>
`;
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 = `
<div class="hockey-settings-backdrop"></div>
<form class="hockey-settings-card">
<header>
<div><small>STAT2TV</small><strong>${escapeHtml(t("settings"))}</strong></div>
<button type="button" data-settings-close>×</button>
</header>
<div class="hockey-settings-body">
<label class="wide">
<span>${escapeHtml(t("apiAddress"))}</span>
<input name="base_url" value="${escapeHtml(value.base_url || "")}">
</label>
<label>
<span>${escapeHtml(t("username"))}</span>
<input name="username" placeholder="${escapeHtml(value.username_masked || "")}" autocomplete="username">
</label>
<label>
<span>${escapeHtml(t("password"))}</span>
<input name="password" type="password" autocomplete="new-password">
</label>
<label>
<span>${escapeHtml(t("authMode"))}</span>
<select name="auth_mode">
<option value="auto">Auto</option>
<option value="basic">Basic</option>
<option value="digest">Digest</option>
</select>
</label>
<label>
<span>${escapeHtml(t("displayLanguage"))}</span>
<select name="ui_language">
<option value="ru">Русский</option>
<option value="en">English</option>
</select>
</label>
<label>
<span>${escapeHtml(t("fallbackLanguage"))}</span>
<select name="fallback_language">
<option value="ru">Русский</option>
<option value="en">English</option>
</select>
</label>
<label>
<span>${escapeHtml(t("interval"))}</span>
<input name="sync_interval_seconds" type="number" min="30" max="86400" value="${Number(value.sync_interval_seconds || 300)}">
</label>
<label>
<span>${escapeHtml(t("cacheSeconds"))}</span>
<input name="games_cache_seconds" type="number" min="0" max="86400" value="${Number(value.games_cache_seconds || 300)}">
</label>
<section class="hockey-settings-section wide">
<strong>${state.language === "en" ? "GAME CLOCK" : "ТАЙМЕР МАТЧА"}</strong>
<small>${state.language === "en" ? "Default duration for a new game and automatic reset when the period changes." : "Длительность по умолчанию для нового матча и автоматический сброс при смене периода."}</small>
</section>
<label>
<span>${state.language === "en" ? "Regulation period, min" : "Обычный период, мин"}</span>
<input name="timer_period_minutes" type="number" min="1" max="60" value="${Number(value.timer_period_minutes || 20)}">
</label>
<label>
<span>${state.language === "en" ? "Regular-season OT, min" : "Овертайм регулярки, мин"}</span>
<input name="timer_regular_overtime_minutes" type="number" min="1" max="60" value="${Number(value.timer_regular_overtime_minutes || 5)}">
</label>
<label>
<span>${state.language === "en" ? "Playoff OT, min" : "Овертайм плей-офф, мин"}</span>
<input name="timer_playoff_overtime_minutes" type="number" min="1" max="60" value="${Number(value.timer_playoff_overtime_minutes || 20)}">
</label>
<label class="check">
<input name="timer_reset_on_period_change" type="checkbox" ${value.timer_reset_on_period_change !== false ? "checked" : ""}>
<span>${state.language === "en" ? "Reset clock when period changes" : "Сбрасывать таймер при смене периода"}</span>
</label>
<section class="hockey-settings-section wide">
<strong>${state.language === "en" ? "PENALTY / STRENGTH GRAPHIC" : "УДАЛЕНИЯ И ЧИСЛЕННЫЕ СОСТАВЫ"}</strong>
<small>${state.language === "en" ? "Choose the base skater count and what the advantaged team receives in the graphic." : "Настройте базовый состав и подпись, которую получает команда в большинстве."}</small>
</section>
<label>
<span>${state.language === "en" ? "Regulation base" : "Основное время, базовый состав"}</span>
<input name="strength_regulation_skaters" type="number" min="3" max="6" value="${Number(value.strength_regulation_skaters || 5)}">
</label>
<label>
<span>${state.language === "en" ? "Regular OT base" : "Овертайм регулярки, базовый состав"}</span>
<input name="strength_regular_overtime_skaters" type="number" min="3" max="6" value="${Number(value.strength_regular_overtime_skaters || 3)}">
</label>
<label>
<span>${state.language === "en" ? "Playoff OT base" : "Овертайм плей-офф, базовый состав"}</span>
<input name="strength_playoff_overtime_skaters" type="number" min="3" max="6" value="${Number(value.strength_playoff_overtime_skaters || 5)}">
</label>
<label>
<span>${state.language === "en" ? "Minimum skaters" : "Минимум полевых игроков"}</span>
<input name="strength_min_skaters" type="number" min="2" max="5" value="${Number(value.strength_min_skaters || 3)}">
</label>
<div class="hockey-settings-note wide">
<strong>${state.language === "en" ? "Bilingual strength labels" : "Двуязычные подписи составов"}</strong>
<small>${state.language === "en" ? "Configure every 5×4, 4×4, 5×3, 4×3 and overtime label in Gear → Timers and lineups." : "Подписи для 5×4, 4×4, 5×3, 4×3 и всех вариантов овертайма настраиваются в разделе Шестерёнка → Таймеры и составы."}</small>
</div>
<label class="wide">
<span>${escapeHtml(t("endpointTemplate"))}</span>
<input name="games_endpoint_template" value="${escapeHtml(value.games_endpoint_template || "")}" placeholder="games.xml?tournament={tournament_id}">
<small>${escapeHtml(t("endpointHint"))}</small>
</label>
<label class="wide">
<span>Players XML</span>
<input name="players_endpoint_template" value="${escapeHtml(value.players_endpoint_template || "{tournament_id}/players-{tournament_id}.xml")}" placeholder="{tournament_id}/players-{tournament_id}.xml">
<small>${state.language === "en" ? "Full season roster for the selected tournament." : "Полный список игроков текущего сезона и выбранной лиги."}</small>
</label>
<label class="wide">
<span>Players countries XML</span>
<input name="player_countries_endpoint_template" value="${escapeHtml(value.player_countries_endpoint_template || "players-countries.xml")}" placeholder="players-countries.xml">
<small>${state.language === "en" ? "Global country directory and player-country links." : "Глобальный справочник стран и привязки игроков к странам."}</small>
</label>
<label class="wide">
<span>Referees XML</span>
<input name="referees_endpoint_template" value="${escapeHtml(value.referees_endpoint_template || "{tournament_id}/referees-{tournament_id}.xml")}" placeholder="{tournament_id}/referees-{tournament_id}.xml">
</label>
<label class="wide">
<span>Shots JSON</span>
<input name="shots_endpoint_template" value="${escapeHtml(value.shots_endpoint_template || "shots/shots-{game_id}.json")}" placeholder="shots/shots-{game_id}.json">
<small>${state.language === "en" ? "Optional resource. It is requested only when available." : "Необязательный ресурс. При отсутствии файла используется отрицательный кэш."}</small>
</label>
<label class="check">
<input name="verify_ssl" type="checkbox" ${value.verify_ssl ? "checked" : ""}>
<span>${escapeHtml(t("verifySsl"))}</span>
</label>
<label class="check">
<input name="games_auto_discovery" type="checkbox" ${value.games_auto_discovery !== false ? "checked" : ""}>
<span>${escapeHtml(t("endpointAuto"))}</span>
</label>
<p class="wide">${escapeHtml(t("credentialsHint"))}</p>
</div>
<footer>
<button type="button" class="btn" data-test-connection>${escapeHtml(t("test"))}</button>
<span data-settings-status></span>
<button type="submit" class="btn btn-accent">${escapeHtml(t("save"))}</button>
</footer>
</form>
`;
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();
})();