Files
hockey_new/hockey_data/static/tournament-menu.js

2603 lines
109 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,
settingsPromise: null,
settingsLoadedAt: 0,
root: null,
drawer: null,
backdrop: null,
selectedChip: null,
settingsModal: null,
gamesAutoSyncAttempted: new Set(),
scheduleSyncBusy: false,
gameOpenBusy: false,
gameOpenGeneration: 0,
gamePollTimer: null,
gamePollBusy: false,
gamePollErrorCount: 0,
gamePollStoppedGameId: "",
currentUser: null,
isAdmin: false,
lastAttemptedGameId: "",
lastGameOpenError: "",
};
const text = {
ru: {
title: "Хоккей",
tournaments: "Турниры",
matches: "Матчи",
today: "Сегодня",
all: "Все турниры",
search: "Поиск турнира…",
league: "Лига",
season: "Сезон",
stage: "Стадия",
allValues: "Все",
noToday: "На выбранную дату активных турниров нет",
fallback: "Показываем последний доступный сезон",
noItems: "Турниры не найдены",
settings: "Настройки Stat2TV",
sync: "Обновить турниры",
configured: "Подключение настроено",
notConfigured: "Нужно настроить подключение",
selectedTournament: "Выбран турнир",
selectedGame: "Выбран матч",
changeTournament: "Сменить турнир",
scheduleCached: "Расписание загружено · даты переключаются локально",
chooseTournament: "Выбрать турнир",
liveUpdate: "Автообновление 1 сек",
updateStopped: "Матч завершён · обновление остановлено",
close: "Закрыть",
back: "Назад к турнирам",
save: "Сохранить",
test: "Проверить соединение",
importSeed: "Импортировать приложенный XML",
apiAddress: "Адрес API",
username: "Логин",
password: "Новый пароль",
authMode: "Авторизация",
displayLanguage: "Язык данных в вебе",
fallbackLanguage: "Резервный язык",
interval: "Интервал обновления, сек",
verifySsl: "Проверять SSL",
credentialsHint: "Пустой пароль оставляет прежний. Данные сохраняются только локально на сервере.",
saved: "Настройки сохранены",
connectionOk: "Соединение установлено",
syncOk: "Турниры обновлены",
sourceSeed: "Сейчас используются данные приложенного XML",
sourceApi: "Последняя синхронизация из Stat2TV",
offSeason: "Межсезонье",
gamesForDate: "Матчи на дату",
noGames: "На выбранную дату матчей нет",
noGamesLoaded: "Календарь матчей ещё не загружен",
updateGames: "Обновить матчи",
discovering: "Ищем XML матчей…",
loadingGames: "Загружаем календарь…",
gamesLoaded: "Матчи загружены",
openGame: "Открыть матч",
gameOpened: "Матч открыт",
loadingMatchTitle: "Загружаем матч",
loadingMatchHint: "Пожалуйста, дождитесь окончания загрузки и ничего не нажимайте",
loadingMatchDetails: "Получаем составы и статистику матча…",
loadingMatchSession: "Создаём рабочую сессию…",
loadingMatchStandings: "Обновляем турнирные данные…",
loadingMatchFinishing: "Подготавливаем интерфейс…",
endpointTemplate: "Шаблон XML матчей",
endpointHint: "Оставьте пустым для автоматического поиска. Доступны переменные {tournament_id}, {date}, {season}.",
endpointAuto: "Автоматически искать endpoint матчей",
cacheSeconds: "Кэш матчей, сек",
rediscover: "Найти endpoint заново",
diagnostics: "Диагностика",
connections: "Подключения",
checkAgain: "Проверить ещё раз",
deepMatchTest: "Проверить выбранный матч",
matchSelectionReady: "Открытие матча",
remoteSyncReady: "Удалённая синхронизация",
database: "PostgreSQL",
stat2tv: "Stat2TV",
agentVmix: "Agent / vMix",
selectedMatchCheck: "Выбранный матч",
optionalConnection: "не требуется для открытия матча",
adminOnly: "Только для администратора",
startTime: "Начало",
arena: "Арена",
gameNumber: "Матч",
score: "Счёт",
unknownTeams: "Команды не распознаны",
syncFailed: "Не удалось загрузить матчи",
currentSession: "Активная сессия",
datePrevious: "Предыдущий день",
dateNext: "Следующий день",
dateToday: "Перейти к сегодняшней дате",
cached: "Из локальной базы",
fromApi: "Stat2TV",
endpointFound: "Endpoint матчей найден",
endpointNotFound: "Endpoint матчей пока не найден",
statusScheduled: "Не начался",
statusLive: "Идёт",
statusFinished: "Завершён",
series: "Серия",
manualMatchTitle: "Загрузка по ID матча",
manualMatchHint: "Укажите ID матча — лига и турнир будут определены автоматически по доступному JSON.",
manualMatchPlaceholder: "Например, 901687",
manualMatchButton: "Загрузить",
manualMatchInvalid: "Укажите корректный ID матча",
liveDataUpdated: "Данные матча обновлены",
finishOvertime: "ОТ",
finishShootout: "Б",
},
en: {
title: "Hockey",
tournaments: "Tournaments",
matches: "Games",
today: "Today",
all: "All tournaments",
search: "Search tournaments…",
league: "League",
season: "Season",
stage: "Stage",
allValues: "All",
noToday: "No active tournaments for this date",
fallback: "Showing the latest available season",
noItems: "No tournaments found",
settings: "Stat2TV settings",
sync: "Update tournaments",
configured: "Connection configured",
notConfigured: "Connection setup required",
selectedTournament: "Selected tournament",
selectedGame: "Selected game",
changeTournament: "Change tournament",
scheduleCached: "Schedule loaded · dates switch locally",
chooseTournament: "Select tournament",
liveUpdate: "Auto refresh 1 sec",
updateStopped: "Game finished · refresh stopped",
close: "Close",
back: "Back to tournaments",
save: "Save",
test: "Test connection",
importSeed: "Import attached XML",
apiAddress: "API address",
username: "Login",
password: "New password",
authMode: "Authentication",
displayLanguage: "Web data language",
fallbackLanguage: "Fallback language",
interval: "Refresh interval, sec",
verifySsl: "Verify SSL",
credentialsHint: "Empty password keeps the current one. Credentials are stored locally on the server.",
saved: "Settings saved",
connectionOk: "Connection established",
syncOk: "Tournaments updated",
sourceSeed: "Attached XML data is currently used",
sourceApi: "Last synchronization from Stat2TV",
offSeason: "Off-season",
gamesForDate: "Games on date",
noGames: "No games on the selected date",
noGamesLoaded: "The game calendar has not been loaded yet",
updateGames: "Update games",
discovering: "Discovering games XML…",
loadingGames: "Loading schedule…",
gamesLoaded: "Games loaded",
openGame: "Open game",
gameOpened: "Game opened",
loadingMatchTitle: "Loading game",
loadingMatchHint: "Please wait until loading is complete and do not click anything",
loadingMatchDetails: "Loading rosters and game statistics…",
loadingMatchSession: "Creating the operator session…",
loadingMatchStandings: "Updating tournament data…",
loadingMatchFinishing: "Preparing the interface…",
endpointTemplate: "Games XML template",
endpointHint: "Leave blank for automatic discovery. Variables: {tournament_id}, {date}, {season}.",
endpointAuto: "Automatically discover games endpoint",
cacheSeconds: "Games cache, sec",
rediscover: "Rediscover endpoint",
diagnostics: "Diagnostics",
connections: "Connections",
checkAgain: "Check again",
deepMatchTest: "Check selected game",
matchSelectionReady: "Game selection",
remoteSyncReady: "Remote sync",
database: "PostgreSQL",
stat2tv: "Stat2TV",
agentVmix: "Agent / vMix",
selectedMatchCheck: "Selected game",
optionalConnection: "not required to open a game",
adminOnly: "Administrator only",
startTime: "Start",
arena: "Arena",
gameNumber: "Game",
score: "Score",
unknownTeams: "Teams were not recognized",
syncFailed: "Failed to load games",
currentSession: "Active session",
datePrevious: "Previous day",
dateNext: "Next day",
dateToday: "Go to today",
cached: "Local database",
fromApi: "Stat2TV",
endpointFound: "Games endpoint found",
endpointNotFound: "Games endpoint not found yet",
statusScheduled: "Scheduled",
statusLive: "Live",
statusFinished: "Finished",
series: "Series",
manualMatchTitle: "Load by game ID",
manualMatchHint: "Enter a game ID — the league and tournament will be detected automatically from the available JSON.",
manualMatchPlaceholder: "For example, 901687",
manualMatchButton: "Load",
manualMatchInvalid: "Enter a valid game ID",
liveDataUpdated: "Match data updated",
finishOvertime: "OT",
finishShootout: "SO",
},
};
function t(key) {
return text[state.language]?.[key] || text.ru[key] || key;
}
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&")
.replaceAll("<", "&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();
}
function rememberAssignedAgent(deviceId) {
const clean = String(deviceId || "").trim();
if (!clean) return;
localStorage.setItem(storage.vmixDevice, clean);
try { window.HockeyAgentRuntime?.selectDevice?.(clean); } catch (_) {}
}
async function ensureSessionAgentAssignment(session) {
const token = String(session?.token || localStorage.getItem(storage.session) || "").trim();
let assignment = session?.agent_assignment && typeof session.agent_assignment === "object"
? session.agent_assignment
: null;
if (assignment?.device_id) rememberAssignedAgent(assignment.device_id);
if (assignment?.delivered) return assignment;
if (!token) return assignment;
// BUILD111: opening the web match is not proof that Agent received
// match.assign. Recover from stale/empty localStorage only when routing is
// unambiguous: the selected live Agent or exactly one live active Agent.
let devicesPayload = null;
try {
devicesPayload = await request("/api/hockey/agents/devices", { timeoutMs: 3500 });
} catch (_) {
return assignment;
}
const devices = Array.isArray(devicesPayload?.devices) ? devicesPayload.devices : [];
const live = devices.filter((item) => (
item?.paired_to_me
&& item?.active_for_account
&& item?.online
));
const preferredIds = [
String(assignment?.device_id || "").trim(),
currentAgentDeviceId(),
].filter(Boolean);
let target = null;
for (const preferred of preferredIds) {
target = live.find((item) => String(item.device_id || "") === preferred) || null;
if (target) break;
}
if (!target && live.length === 1) target = live[0];
if (!target?.device_id) return assignment;
try {
const rebound = await request(
`/api/hockey/agents/devices/${encodeURIComponent(target.device_id)}/select-session`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session_token: token, apply_mapping: false }),
timeoutMs: 5000,
}
);
assignment = rebound?.assignment || assignment;
if (assignment?.device_id) rememberAssignedAgent(assignment.device_id);
if (session && assignment) session.agent_assignment = assignment;
return assignment;
} catch (error) {
console.warn("[Hockey] Agent match.assign retry failed:", error);
return assignment;
}
}
async function refreshSessionAgentMapping(session) {
const assignment = await ensureSessionAgentAssignment(session);
const deviceId = String(assignment?.device_id || "").trim();
if (!deviceId || !assignment?.delivered) {
return { ok: false, reason: "agent_match_not_delivered" };
}
return request(
`/api/hockey/agents/devices/${encodeURIComponent(deviceId)}/apply-mapping`,
{ method: "POST", timeoutMs: 60000 }
);
}
async function ensureAccountScopedRuntimeState() {
let user = null;
try {
user = await request("/api/hockey/me");
} catch (_) {
return;
}
state.currentUser = user;
state.isAdmin = Boolean(user?.is_admin);
const editorButton = document.getElementById("runtimeEditorBtn");
editorButton?.classList.toggle("hidden", !state.isAdmin);
document.body.dataset.hockeyAdmin = state.isAdmin ? "1" : "0";
const currentAccountId = String(user?.id || "").trim();
if (!currentAccountId) return;
const previousAccountId = String(localStorage.getItem(storage.account) || "").trim();
if (previousAccountId && previousAccountId !== currentAccountId) {
// Session tokens and Agent selection belong to one WFL account only.
// Keeping them across logout/login can bind a new operator to the
// previous account's match or vMix device.
localStorage.removeItem(storage.session);
localStorage.removeItem(storage.game);
localStorage.removeItem(storage.vmixDevice);
state.selectedGameId = "";
state.selectedGame = null;
state.selectedMatchDetails = null;
stopSelectedGamePolling();
try { window.HockeyAgentRuntime?.selectDevice?.(""); } catch (_) {}
}
localStorage.setItem(storage.account, currentAccountId);
}
async function request(path, options = {}) {
const { timeoutMs = 15000, ...fetchOptions } = options;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(path, {
cache: "no-store",
credentials: "same-origin",
...fetchOptions,
signal: controller.signal,
});
let payload = {};
try {
payload = await response.json();
} catch (_) {}
if (!response.ok) {
if (response.status === 401) {
window.location.assign("/");
}
const detail = payload.detail;
throw new Error(
typeof detail === "string"
? detail
: detail?.message || `HTTP ${response.status}`
);
}
return payload;
} catch (error) {
if (error?.name === "AbortError") {
// BUILD109: this helper is used for local DB/session/Agent endpoints too.
// Calling every HTTP timeout a Stat2TV timeout hides the real subsystem.
throw new Error("Превышено время ожидания ответа сервера");
}
throw error;
} finally {
clearTimeout(timeout);
}
}
function notify(message, error = false) {
if (!error && window.HOCKEY_RUNTIME_TOASTS_ENABLED === false) return;
let toast = document.querySelector(".hockey-module-toast");
if (!toast) {
toast = document.createElement("div");
toast.className = "hockey-module-toast";
document.body.appendChild(toast);
}
toast.textContent = message;
toast.classList.toggle("error", error);
toast.classList.add("show");
clearTimeout(toast._hideTimer);
toast._hideTimer = setTimeout(() => toast.classList.remove("show"), 3200);
}
function gameById(id) {
return (state.games?.items || []).find(
(item) => String(item?.external_id || item?.id || "") === String(id || "")
) || null;
}
function matchLabelById(id) {
const game = gameById(id);
if (!game) return "";
const home = game.home?.name || game.home_name || game.team1 || "";
const away = game.away?.name || game.away_name || game.team2 || "";
return [home, away].filter(Boolean).join(" — ");
}
function setMatchLoading(visible, { game = "", stage = "" } = {}) {
const overlay = state.root?.querySelector("[data-match-loading]");
if (!overlay) return;
const titleNode = overlay.querySelector("[data-match-loading-title]");
const gameNode = overlay.querySelector("[data-match-loading-game]");
const stageNode = overlay.querySelector("[data-match-loading-stage]");
const hintNode = overlay.querySelector("[data-match-loading-hint]");
if (titleNode) titleNode.textContent = t("loadingMatchTitle");
if (gameNode) {
gameNode.textContent = game || "";
gameNode.classList.toggle("hidden", !game);
}
if (stageNode) stageNode.textContent = stage || t("loadingMatchDetails");
if (hintNode) hintNode.textContent = t("loadingMatchHint");
overlay.classList.toggle("show", Boolean(visible));
overlay.setAttribute("aria-hidden", visible ? "false" : "true");
document.documentElement.classList.toggle("hockey-match-loading-active", Boolean(visible));
}
function updateMatchLoading(stage) {
const overlay = state.root?.querySelector("[data-match-loading]");
const stageNode = overlay?.querySelector("[data-match-loading-stage]");
if (stageNode) stageNode.textContent = stage || t("loadingMatchDetails");
}
function toolbarHost() {
return boot.mode === "editor"
? document.querySelector(".topbar .top-actions")
: document.querySelector(".runtime-actions");
}
function ensureShell() {
if (state.root) return;
const toolbar = toolbarHost();
if (toolbar) {
const chip = document.createElement("button");
chip.type = "button";
chip.className = "hockey-selected-chip placeholder";
chip.addEventListener("click", () => {
state.view = state.selectedTournament ? "games" : "tournaments";
setOpen(true);
renderView();
});
toolbar.prepend(chip);
state.selectedChip = chip;
}
const root = document.createElement("div");
root.className = "hockey-navigation-root";
root.innerHTML = `
<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, { opening = false } = {}) {
try {
const loaded = await request(
`/api/hockey/games/${encodeURIComponent(id)}/details/sync`
+ `?tournament_id=${encodeURIComponent(tournamentId)}`
+ `&language=${encodeURIComponent(state.language)}`
+ `&opening=${opening ? "true" : "false"}`,
{ method: "POST", timeoutMs: opening ? 14000 : 16000 }
);
if (
generation !== state.gameOpenGeneration
|| String(state.selectedGameId || "") !== String(id)
) return false;
if (loaded?.game) state.selectedGame = loaded.game;
state.selectedMatchDetails = loaded?.details || state.selectedMatchDetails || null;
applyGameData();
renderSelectedChip();
renderGames();
if (loaded?.details_sync_failed || loaded?.degraded) {
console.warn("[Hockey] Match opened with cached/schedule data; full details are not available yet", loaded?.warnings || []);
}
return Boolean(loaded?.game);
} catch (error) {
// Full match JSON, rosters, shots and directories are enrichment data.
// Their absence must never undo a match already selected by the operator.
console.warn(`[Hockey] Optional match details sync failed for ${id}:`, error);
return false;
}
}
function clearRuntimeMatchSpecificData() {
// BUILD110: patchData() performs a deep merge. A schedule row normally has
// no home.players/away.players fields, so without an explicit reset the
// previous match roster survives until the new details request completes.
window.UIBuilderRuntime?.patchData?.({
hockey: {
selected_game: null,
home: null,
away: null,
referees: [],
match_details: null,
game_control: null,
selected_penalties: { home: null, away: null },
active_penalties: { home: null, away: null },
},
});
}
function cachedMatchHasRosters(payload) {
if (!payload?.game || !payload?.details?.loaded) return false;
const home = Array.isArray(payload.game?.home?.players) ? payload.game.home.players.length : 0;
const away = Array.isArray(payload.game?.away?.players) ? payload.game.away.players.length : 0;
return (home + away) > 0;
}
async function openGame(id, { manual = false } = {}) {
if (state.gameOpenBusy) return;
state.lastAttemptedGameId = String(id || "");
state.lastGameOpenError = "";
state.gameOpenBusy = true;
stopSelectedGamePolling();
const generation = ++state.gameOpenGeneration;
const loadingGame = matchLabelById(id) || `${t("gameNumber")} ID ${id}`;
setMatchLoading(true, { game: loadingGame, stage: t("loadingMatchDetails") });
try {
// Match selection itself depends only on the local/cached schedule row.
// Full Stat2TV match JSON is optional enrichment and is loaded later.
const scheduleGame = gameById(id);
if (manual) {
// Manual ID entry still performs global tournament discovery because the
// game may not exist in the local schedule yet.
const discovered = await request(
`/api/hockey/games/manual/${encodeURIComponent(id)}?language=${encodeURIComponent(state.language)}`,
{ method: "POST", timeoutMs: 60000 }
);
state.selectedGame = discovered.game;
state.selectedMatchDetails = discovered.details || null;
} else if (scheduleGame) {
// BUILD109: the card the operator clicked is already a complete local
// schedule snapshot. Opening the workspace must be immediate; cached
// details/full match data are optional enrichment and are loaded below.
state.selectedGame = scheduleGame;
state.selectedMatchDetails = null;
} else {
// Defensive fallback for a stale DOM/list state. This path is local DB
// only and is not used for the normal visible match-card click.
state.selectedGame = await request(
`/api/hockey/games/${encodeURIComponent(id)}?language=${encodeURIComponent(state.language)}`,
{ timeoutMs: 5000 }
);
state.selectedMatchDetails = null;
}
const actualTournamentId = String(
state.selectedGame?.tournament_external_id || state.selectedId || ""
).trim();
if (!actualTournamentId) throw new Error("Не удалось определить турнир выбранного матча");
if (actualTournamentId !== String(state.selectedId || "")) {
state.selectedId = actualTournamentId;
localStorage.setItem(storage.tournament, state.selectedId);
try {
state.selectedTournament = await request(
`/api/hockey/tournaments/${encodeURIComponent(state.selectedId)}?language=${encodeURIComponent(state.language)}`,
{ timeoutMs: 5000 }
);
} catch (_) {
state.selectedTournament = state.selectedTournament || null;
}
state.standings = null;
state.tournamentStatistics = null;
state.games = null;
state.teamSchedule = null;
state.teamScheduleTournamentId = "";
applyTournamentData();
}
if (manual && state.selectedGame?.tournament_external_id) {
state.selectedId = String(state.selectedGame.tournament_external_id);
localStorage.setItem(storage.tournament, state.selectedId);
}
// Read only the selected game from the local DB while the session is being
// created. This never contacts Stat2TV and lets a previously loaded match
// restore its own roster immediately, without exposing another game's data.
const cachedDetailsPromise = manual
? Promise.resolve(null)
: request(
`/api/hockey/games/${encodeURIComponent(id)}/details?language=${encodeURIComponent(state.language)}`,
{ timeoutMs: 2000 }
).catch(() => null);
updateMatchLoading(t("loadingMatchSession"));
const previousToken = localStorage.getItem(storage.session);
if (previousToken) {
try {
const oldSession = await request(`/api/hockey/sessions/${encodeURIComponent(previousToken)}`);
if (String(oldSession.game_external_id || "") !== String(id)) {
await request(`/api/hockey/sessions/${encodeURIComponent(previousToken)}`, { method: "DELETE" });
localStorage.removeItem(storage.session);
}
} catch (_) {
localStorage.removeItem(storage.session);
}
}
let session = null;
const selectedDeviceId = currentAgentDeviceId();
const sessionPayload = {
tournament_external_id: state.selectedId,
game_external_id: String(id),
display_language: state.language,
vmix_language: state.language,
vmix_device_id: selectedDeviceId,
replace_existing: true,
};
try {
session = await request("/api/hockey/sessions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(sessionPayload),
});
} catch (sessionError) {
// Retry the operator session without Agent if the browser carries a stale device.
if (selectedDeviceId) {
localStorage.removeItem(storage.vmixDevice);
try { window.HockeyAgentRuntime?.selectDevice?.(""); } catch (_) {}
session = await request("/api/hockey/sessions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...sessionPayload, vmix_device_id: "" }),
});
} else {
throw sessionError;
}
}
// Confirm that the concrete Agent actually received this new match.
// A successful /sessions response alone is not enough: assignment may be
// null/offline when the browser stored a stale device id.
const initialAgentAssignment = await ensureSessionAgentAssignment(session);
if (initialAgentAssignment && session) session.agent_assignment = initialAgentAssignment;
state.lastGameOpenError = "";
state.selectedGameId = String(id);
localStorage.setItem(storage.game, state.selectedGameId);
if (session?.token) localStorage.setItem(storage.session, session.token);
else localStorage.removeItem(storage.session);
const selectedGameDate = String(state.selectedGame?.date || "").trim();
if (selectedGameDate) {
state.gameDate = selectedGameDate;
localStorage.setItem(storage.gameDate, state.gameDate);
}
// Prefer cached details for this exact game. The Runtime was explicitly
// cleared above, so even a missing cache can only show an empty new match,
// never the previous match roster.
// The new session is confirmed. From this point the old match-specific
// Runtime payload must not survive even for a single render.
clearRuntimeMatchSpecificData();
const cachedDetails = await cachedDetailsPromise;
const cachedRostersReady = cachedMatchHasRosters(cachedDetails);
if (cachedDetails?.game) {
state.selectedGame = cachedDetails.game;
state.selectedMatchDetails = cachedDetails.details || null;
}
applyGameData(session);
renderSelectedChip();
setOpen(false);
// For a first-time match, keep the loading overlay until the critical
// roster card has arrived. Mapping and optional shots remain non-blocking.
// If this game's own cached roster already exists, let the operator work
// immediately and refresh it in the background.
if (!manual && !cachedRostersReady) {
updateMatchLoading(t("loadingMatchDetails"));
await enrichOpenedGame(String(id), state.selectedId, generation, { opening: true });
if (
generation === state.gameOpenGeneration
&& String(state.selectedGameId || "") === String(id)
) {
startSelectedGamePolling();
void refreshSessionAgentMapping(session).then((mapping) => {
if (mapping?.ok === false && mapping?.reason === "agent_match_not_delivered") {
notify("Матч открыт, но Agent не получил новый матч", true);
}
}).catch((error) => console.warn("[Hockey] Mapping refresh after roster failed:", error));
}
} else if (!manual) {
// This exact match already has its own cached roster, so vMix can be
// refreshed immediately without waiting for the optional network sync.
void refreshSessionAgentMapping(session).then((mapping) => {
if (mapping?.ok === false && mapping?.reason === "agent_match_not_delivered") {
notify("Матч открыт, но Agent не получил новый матч", true);
}
}).catch((error) => console.warn("[Hockey] Mapping refresh from cached roster failed:", error));
void enrichOpenedGame(String(id), state.selectedId, generation, { opening: true })
.finally(() => {
if (
generation === state.gameOpenGeneration
&& String(state.selectedGameId || "") === String(id)
) {
startSelectedGamePolling();
}
});
} else {
startSelectedGamePolling();
void refreshSessionAgentMapping(session).catch((error) => console.warn("[Hockey] Mapping refresh for manual match failed:", error));
}
updateMatchLoading(t("loadingMatchFinishing"));
loadTournamentStandings({ sync: false }).catch(() => {});
if (selectedGameDate) loadGames({ autoSync: false }).catch(() => {});
const playersCount = (state.selectedGame?.home?.players?.length || 0)
+ (state.selectedGame?.away?.players?.length || 0);
const refereesCount = state.selectedGame?.referees?.length || 0;
const loadedText = state.selectedMatchDetails?.loaded
? ` · ${playersCount} ${state.language === "en" ? "players" : "игроков"}`
+ ` · ${refereesCount} ${state.language === "en" ? "officials" : "судей"}`
: "";
const homeName = state.selectedGame?.home?.name || "—";
const awayName = state.selectedGame?.away?.name || "—";
notify(`${t("gameOpened")}: ${homeName}${awayName}${loadedText}`);
if (manual) state.root?.querySelectorAll("[data-manual-game-id]").forEach((manualInput) => { manualInput.value = ""; });
} catch (error) {
state.lastGameOpenError = String(error?.message || error || "Неизвестная ошибка");
if (state.view === "games") renderGames();
notify(state.lastGameOpenError, true);
} finally {
setMatchLoading(false);
state.gameOpenBusy = false;
}
}
function applyGameData(session = null) {
if (!state.selectedGame) return;
const token = session?.token || localStorage.getItem(storage.session) || "";
window.UIBuilderRuntime?.patchData?.({
hockey: {
selected_game: state.selectedGame,
schedule: schedulePayload(),
team_schedule: state.teamSchedule || { meta: {}, items: [] },
home: state.selectedGame.home || { players: [] },
away: state.selectedGame.away || { players: [] },
referees: state.selectedGame.referees || [],
match_details: state.selectedMatchDetails || null,
standings: state.standings,
tournament_statistics: state.tournamentStatistics,
stat_labels: state.statLabels,
operator_session: session || (token ? { token } : null),
},
});
window.dispatchEvent(new CustomEvent("hockey:game-selected", {
detail: {
game: state.selectedGame,
details: state.selectedMatchDetails,
session: session || (token ? { token } : null),
language: state.language,
},
}));
}
function renderSelectedChip() {
if (!state.selectedChip) return;
state.selectedChip.classList.remove("hidden", "placeholder", "polling", "finished");
if (state.selectedGame) {
const finished = Boolean(state.selectedGame.is_finished);
state.selectedChip.classList.toggle("finished", finished);
state.selectedChip.classList.toggle("polling", !finished);
state.selectedChip.dataset.tooltip = finished
? t("updateStopped")
: t("liveUpdate");
state.selectedChip.innerHTML = `
<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;
}
}
function loadSettingsCached({ force = false } = {}) {
const fresh = state.settings && (Date.now() - Number(state.settingsLoadedAt || 0)) < 30000;
if (!force && fresh) return Promise.resolve(state.settings);
if (state.settingsPromise) return state.settingsPromise;
state.settingsPromise = request("/api/hockey/settings", { timeoutMs: 5000 })
.then((payload) => {
state.settings = payload || {};
state.settingsLoadedAt = Date.now();
return state.settings;
})
.finally(() => { state.settingsPromise = null; });
return state.settingsPromise;
}
async function openSettings() {
let loadingModal = null;
if (!state.settings) {
loadingModal = document.createElement("div");
loadingModal.className = "hockey-settings-modal";
loadingModal.innerHTML = `
<div class="hockey-settings-backdrop"></div>
<div class="hockey-settings-card">
<header><div><small>STAT2TV</small><strong>${escapeHtml(t("settings"))}</strong></div></header>
<div class="hockey-settings-body"><p class="wide">${state.language === "en" ? "Loading settings…" : "Загрузка настроек…"}</p></div>
</div>`;
document.body.appendChild(loadingModal);
}
try {
state.settings = await loadSettingsCached();
} catch (error) {
loadingModal?.remove();
notify(error.message, true);
return;
}
loadingModal?.remove();
const value = state.settings;
const modal = document.createElement("div");
modal.className = "hockey-settings-modal";
modal.innerHTML = `
<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")),
}),
});
state.settings = savedSettings || state.settings || {};
state.settingsLoadedAt = Date.now();
const username = String(values.get("username") || "").trim();
const password = String(values.get("password") || "");
if (username || password) {
await request("/api/hockey/credentials", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username: username || null,
password: password || null,
}),
});
}
const previousLanguage = String(state.language || "ru").toLowerCase();
const nextLanguage = String(values.get("ui_language") || state.language || "ru").toLowerCase();
state.language = nextLanguage;
localStorage.setItem(storage.language, state.language);
window.dispatchEvent(new CustomEvent("hockey:settings-updated", {
detail: { settings: savedSettings || {} },
}));
// Mapping can use virtual *_RUS/*_ENG columns. Language is a global
// Hockey setting, while live Agents may belong to other operator accounts.
// Therefore refresh every active broadcast Agent, not only the admin's own.
let mappingRefresh = null;
if (previousLanguage !== nextLanguage) {
try {
mappingRefresh = await request("/api/hockey/admin/vmix-mapping/apply-all-active", { method: "POST" });
} catch (mappingError) {
status.textContent = `${t("saved")} · vMix: ${mappingError.message}`;
status.classList.add("error");
}
}
if (!status.classList.contains("error")) {
if (mappingRefresh && Number(mappingRefresh.total_devices || 0) > 0) {
status.textContent = `${t("saved")} · vMix ${Number(mappingRefresh.applied_devices || 0)}/${Number(mappingRefresh.total_devices || 0)}`;
} else {
status.textContent = t("saved");
}
status.classList.remove("error");
}
setTimeout(close, 120);
loadNavigation().catch((error) => console.warn("[Hockey] Navigation refresh after settings save failed:", error));
} catch (error) {
status.textContent = error.message;
status.classList.add("error");
}
});
}
async function init() {
await ensureAccountScopedRuntimeState();
ensureShell();
renderStaticLabels();
// Warm the tiny local settings endpoint while navigation is loading, so the
// gear opens immediately instead of starting its first request on click.
void loadSettingsCached().catch(() => {});
await loadNavigation();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init, { once: true });
} else {
init();
}
window.HockeyTournamentMenu = {
open: () => setOpen(true),
close: () => setOpen(false),
reload: loadNavigation,
getSelectedTournamentId: () => state.selectedId,
getSelectedGameId: () => state.selectedGameId,
selectTournament: openTournament,
selectGame: openGame,
reloadGames: () => loadGames({ autoSync: false }),
};
function installGlobalTooltipPortal() {
if (document.querySelector(".hockey-global-tooltip")) return;
const tooltip = document.createElement("div");
tooltip.className = "hockey-global-tooltip";
document.body.appendChild(tooltip);
let current = null;
const hide = () => {
current = null;
tooltip.classList.remove("show");
tooltip.textContent = "";
};
const position = () => {
if (!current) return;
const rect = current.getBoundingClientRect();
const tipRect = tooltip.getBoundingClientRect();
let left = rect.left + rect.width / 2 - tipRect.width / 2;
left = Math.max(8, Math.min(window.innerWidth - tipRect.width - 8, left));
let top = rect.top - tipRect.height - 10;
if (top < 8) top = rect.bottom + 10;
tooltip.style.left = `${left}px`;
tooltip.style.top = `${Math.max(8, top)}px`;
};
const show = (target) => {
const text = String(target?.dataset?.tooltip || target?.getAttribute?.("aria-label") || "").trim();
if (!text) return;
current = target;
tooltip.textContent = text;
tooltip.classList.add("show");
requestAnimationFrame(position);
};
document.addEventListener("pointerover", (event) => {
const target = event.target.closest?.("[data-tooltip]");
if (target) show(target);
});
document.addEventListener("pointerout", (event) => {
if (!current) return;
const next = event.relatedTarget;
if (next && current.contains(next)) return;
hide();
});
document.addEventListener("focusin", (event) => {
const target = event.target.closest?.("[data-tooltip]");
if (target) show(target);
});
document.addEventListener("focusout", hide);
window.addEventListener("scroll", hide, true);
window.addEventListener("resize", hide);
}
installGlobalTooltipPortal();
})();