2399 lines
100 KiB
JavaScript
2399 lines
100 KiB
JavaScript
(() => {
|
||
"use strict";
|
||
|
||
const boot = window.UI_BUILDER_BOOT || {};
|
||
const storage = {
|
||
language: "hockey.language",
|
||
tournament: "hockey.selectedTournamentId",
|
||
game: "hockey.selectedGameId",
|
||
gameDate: "hockey.gameDate",
|
||
session: "hockey.operatorSessionToken",
|
||
vmixDevice: "hockey.vmix.selected_device",
|
||
account: "hockey.accountId",
|
||
};
|
||
|
||
const state = {
|
||
open: false,
|
||
loading: false,
|
||
language: localStorage.getItem(storage.language) || "ru",
|
||
view: "tournaments",
|
||
mode: "today",
|
||
search: "",
|
||
league: "",
|
||
season: "",
|
||
stage: "",
|
||
selectedId: localStorage.getItem(storage.tournament) || "",
|
||
selectedGameId: localStorage.getItem(storage.game) || "",
|
||
selectedTournament: null,
|
||
selectedGame: null,
|
||
selectedMatchDetails: null,
|
||
standings: null,
|
||
tournamentStatistics: null,
|
||
statLabels: null,
|
||
gameDate: localStorage.getItem(storage.gameDate) || localDateIso(),
|
||
navigation: null,
|
||
games: null,
|
||
teamSchedule: null,
|
||
teamScheduleTournamentId: "",
|
||
settings: null,
|
||
root: null,
|
||
drawer: null,
|
||
backdrop: null,
|
||
selectedChip: null,
|
||
settingsModal: null,
|
||
gamesAutoSyncAttempted: new Set(),
|
||
scheduleSyncBusy: false,
|
||
gameOpenBusy: false,
|
||
gameOpenGeneration: 0,
|
||
gamePollTimer: null,
|
||
gamePollBusy: false,
|
||
gamePollErrorCount: 0,
|
||
gamePollStoppedGameId: "",
|
||
currentUser: null,
|
||
isAdmin: false,
|
||
lastAttemptedGameId: "",
|
||
lastGameOpenError: "",
|
||
};
|
||
|
||
const text = {
|
||
ru: {
|
||
title: "Хоккей",
|
||
tournaments: "Турниры",
|
||
matches: "Матчи",
|
||
today: "Сегодня",
|
||
all: "Все турниры",
|
||
search: "Поиск турнира…",
|
||
league: "Лига",
|
||
season: "Сезон",
|
||
stage: "Стадия",
|
||
allValues: "Все",
|
||
noToday: "На выбранную дату активных турниров нет",
|
||
fallback: "Показываем последний доступный сезон",
|
||
noItems: "Турниры не найдены",
|
||
settings: "Настройки Stat2TV",
|
||
sync: "Обновить турниры",
|
||
configured: "Подключение настроено",
|
||
notConfigured: "Нужно настроить подключение",
|
||
selectedTournament: "Выбран турнир",
|
||
selectedGame: "Выбран матч",
|
||
changeTournament: "Сменить турнир",
|
||
scheduleCached: "Расписание загружено · даты переключаются локально",
|
||
chooseTournament: "Выбрать турнир",
|
||
liveUpdate: "Автообновление 1 сек",
|
||
updateStopped: "Матч завершён · обновление остановлено",
|
||
close: "Закрыть",
|
||
back: "Назад к турнирам",
|
||
save: "Сохранить",
|
||
test: "Проверить соединение",
|
||
importSeed: "Импортировать приложенный XML",
|
||
apiAddress: "Адрес API",
|
||
username: "Логин",
|
||
password: "Новый пароль",
|
||
authMode: "Авторизация",
|
||
displayLanguage: "Язык данных в вебе",
|
||
fallbackLanguage: "Резервный язык",
|
||
interval: "Интервал обновления, сек",
|
||
verifySsl: "Проверять SSL",
|
||
credentialsHint: "Пустой пароль оставляет прежний. Данные сохраняются только локально на сервере.",
|
||
saved: "Настройки сохранены",
|
||
connectionOk: "Соединение установлено",
|
||
syncOk: "Турниры обновлены",
|
||
sourceSeed: "Сейчас используются данные приложенного XML",
|
||
sourceApi: "Последняя синхронизация из Stat2TV",
|
||
offSeason: "Межсезонье",
|
||
gamesForDate: "Матчи на дату",
|
||
noGames: "На выбранную дату матчей нет",
|
||
noGamesLoaded: "Календарь матчей ещё не загружен",
|
||
updateGames: "Обновить матчи",
|
||
discovering: "Ищем XML матчей…",
|
||
loadingGames: "Загружаем календарь…",
|
||
gamesLoaded: "Матчи загружены",
|
||
openGame: "Открыть матч",
|
||
gameOpened: "Матч открыт",
|
||
loadingMatchTitle: "Загружаем матч",
|
||
loadingMatchHint: "Пожалуйста, дождитесь окончания загрузки и ничего не нажимайте",
|
||
loadingMatchDetails: "Получаем составы и статистику матча…",
|
||
loadingMatchSession: "Создаём рабочую сессию…",
|
||
loadingMatchStandings: "Обновляем турнирные данные…",
|
||
loadingMatchFinishing: "Подготавливаем интерфейс…",
|
||
endpointTemplate: "Шаблон XML матчей",
|
||
endpointHint: "Оставьте пустым для автоматического поиска. Доступны переменные {tournament_id}, {date}, {season}.",
|
||
endpointAuto: "Автоматически искать endpoint матчей",
|
||
cacheSeconds: "Кэш матчей, сек",
|
||
rediscover: "Найти endpoint заново",
|
||
diagnostics: "Диагностика",
|
||
connections: "Подключения",
|
||
checkAgain: "Проверить ещё раз",
|
||
deepMatchTest: "Проверить выбранный матч",
|
||
matchSelectionReady: "Открытие матча",
|
||
remoteSyncReady: "Удалённая синхронизация",
|
||
database: "PostgreSQL",
|
||
stat2tv: "Stat2TV",
|
||
agentVmix: "Agent / vMix",
|
||
selectedMatchCheck: "Выбранный матч",
|
||
optionalConnection: "не требуется для открытия матча",
|
||
adminOnly: "Только для администратора",
|
||
startTime: "Начало",
|
||
arena: "Арена",
|
||
gameNumber: "Матч",
|
||
score: "Счёт",
|
||
unknownTeams: "Команды не распознаны",
|
||
syncFailed: "Не удалось загрузить матчи",
|
||
currentSession: "Активная сессия",
|
||
datePrevious: "Предыдущий день",
|
||
dateNext: "Следующий день",
|
||
dateToday: "Перейти к сегодняшней дате",
|
||
cached: "Из локальной базы",
|
||
fromApi: "Stat2TV",
|
||
endpointFound: "Endpoint матчей найден",
|
||
endpointNotFound: "Endpoint матчей пока не найден",
|
||
statusScheduled: "Не начался",
|
||
statusLive: "Идёт",
|
||
statusFinished: "Завершён",
|
||
series: "Серия",
|
||
manualMatchTitle: "Загрузка по ID матча",
|
||
manualMatchHint: "Укажите ID матча — лига и турнир будут определены автоматически по доступному JSON.",
|
||
manualMatchPlaceholder: "Например, 901687",
|
||
manualMatchButton: "Загрузить",
|
||
manualMatchInvalid: "Укажите корректный ID матча",
|
||
liveDataUpdated: "Данные матча обновлены",
|
||
finishOvertime: "ОТ",
|
||
finishShootout: "Б",
|
||
},
|
||
en: {
|
||
title: "Hockey",
|
||
tournaments: "Tournaments",
|
||
matches: "Games",
|
||
today: "Today",
|
||
all: "All tournaments",
|
||
search: "Search tournaments…",
|
||
league: "League",
|
||
season: "Season",
|
||
stage: "Stage",
|
||
allValues: "All",
|
||
noToday: "No active tournaments for this date",
|
||
fallback: "Showing the latest available season",
|
||
noItems: "No tournaments found",
|
||
settings: "Stat2TV settings",
|
||
sync: "Update tournaments",
|
||
configured: "Connection configured",
|
||
notConfigured: "Connection setup required",
|
||
selectedTournament: "Selected tournament",
|
||
selectedGame: "Selected game",
|
||
changeTournament: "Change tournament",
|
||
scheduleCached: "Schedule loaded · dates switch locally",
|
||
chooseTournament: "Select tournament",
|
||
liveUpdate: "Auto refresh 1 sec",
|
||
updateStopped: "Game finished · refresh stopped",
|
||
close: "Close",
|
||
back: "Back to tournaments",
|
||
save: "Save",
|
||
test: "Test connection",
|
||
importSeed: "Import attached XML",
|
||
apiAddress: "API address",
|
||
username: "Login",
|
||
password: "New password",
|
||
authMode: "Authentication",
|
||
displayLanguage: "Web data language",
|
||
fallbackLanguage: "Fallback language",
|
||
interval: "Refresh interval, sec",
|
||
verifySsl: "Verify SSL",
|
||
credentialsHint: "Empty password keeps the current one. Credentials are stored locally on the server.",
|
||
saved: "Settings saved",
|
||
connectionOk: "Connection established",
|
||
syncOk: "Tournaments updated",
|
||
sourceSeed: "Attached XML data is currently used",
|
||
sourceApi: "Last synchronization from Stat2TV",
|
||
offSeason: "Off-season",
|
||
gamesForDate: "Games on date",
|
||
noGames: "No games on the selected date",
|
||
noGamesLoaded: "The game calendar has not been loaded yet",
|
||
updateGames: "Update games",
|
||
discovering: "Discovering games XML…",
|
||
loadingGames: "Loading schedule…",
|
||
gamesLoaded: "Games loaded",
|
||
openGame: "Open game",
|
||
gameOpened: "Game opened",
|
||
loadingMatchTitle: "Loading game",
|
||
loadingMatchHint: "Please wait until loading is complete and do not click anything",
|
||
loadingMatchDetails: "Loading rosters and game statistics…",
|
||
loadingMatchSession: "Creating the operator session…",
|
||
loadingMatchStandings: "Updating tournament data…",
|
||
loadingMatchFinishing: "Preparing the interface…",
|
||
endpointTemplate: "Games XML template",
|
||
endpointHint: "Leave blank for automatic discovery. Variables: {tournament_id}, {date}, {season}.",
|
||
endpointAuto: "Automatically discover games endpoint",
|
||
cacheSeconds: "Games cache, sec",
|
||
rediscover: "Rediscover endpoint",
|
||
diagnostics: "Diagnostics",
|
||
connections: "Connections",
|
||
checkAgain: "Check again",
|
||
deepMatchTest: "Check selected game",
|
||
matchSelectionReady: "Game selection",
|
||
remoteSyncReady: "Remote sync",
|
||
database: "PostgreSQL",
|
||
stat2tv: "Stat2TV",
|
||
agentVmix: "Agent / vMix",
|
||
selectedMatchCheck: "Selected game",
|
||
optionalConnection: "not required to open a game",
|
||
adminOnly: "Administrator only",
|
||
startTime: "Start",
|
||
arena: "Arena",
|
||
gameNumber: "Game",
|
||
score: "Score",
|
||
unknownTeams: "Teams were not recognized",
|
||
syncFailed: "Failed to load games",
|
||
currentSession: "Active session",
|
||
datePrevious: "Previous day",
|
||
dateNext: "Next day",
|
||
dateToday: "Go to today",
|
||
cached: "Local database",
|
||
fromApi: "Stat2TV",
|
||
endpointFound: "Games endpoint found",
|
||
endpointNotFound: "Games endpoint not found yet",
|
||
statusScheduled: "Scheduled",
|
||
statusLive: "Live",
|
||
statusFinished: "Finished",
|
||
series: "Series",
|
||
manualMatchTitle: "Load by game ID",
|
||
manualMatchHint: "Enter a game ID — the league and tournament will be detected automatically from the available JSON.",
|
||
manualMatchPlaceholder: "For example, 901687",
|
||
manualMatchButton: "Load",
|
||
manualMatchInvalid: "Enter a valid game ID",
|
||
liveDataUpdated: "Match data updated",
|
||
finishOvertime: "OT",
|
||
finishShootout: "SO",
|
||
},
|
||
};
|
||
|
||
function t(key) {
|
||
return text[state.language]?.[key] || text.ru[key] || key;
|
||
}
|
||
|
||
function escapeHtml(value) {
|
||
return String(value ?? "")
|
||
.replaceAll("&", "&")
|
||
.replaceAll("<", "<")
|
||
.replaceAll(">", ">")
|
||
.replaceAll('"', """)
|
||
.replaceAll("'", "'");
|
||
}
|
||
|
||
function localDateIso() {
|
||
const now = new Date();
|
||
const offset = now.getTimezoneOffset();
|
||
return new Date(now.getTime() - offset * 60000).toISOString().slice(0, 10);
|
||
}
|
||
|
||
function shiftDate(value, days) {
|
||
const date = new Date(`${value}T12:00:00`);
|
||
date.setDate(date.getDate() + days);
|
||
const offset = date.getTimezoneOffset();
|
||
return new Date(date.getTime() - offset * 60000).toISOString().slice(0, 10);
|
||
}
|
||
|
||
function formatDate(value) {
|
||
if (!value) return "—";
|
||
try {
|
||
return new Intl.DateTimeFormat(
|
||
state.language === "en" ? "en-GB" : "ru-RU",
|
||
{ day: "2-digit", month: "long", year: "numeric" }
|
||
).format(new Date(`${value}T12:00:00`));
|
||
} catch (_) {
|
||
return value;
|
||
}
|
||
}
|
||
|
||
function currentAgentDeviceId() {
|
||
return String(window.HockeyAgentRuntime?.currentDeviceId?.() || localStorage.getItem(storage.vmixDevice) || "").trim();
|
||
}
|
||
|
||
async function ensureAccountScopedRuntimeState() {
|
||
let user = null;
|
||
try {
|
||
user = await request("/api/hockey/me");
|
||
} catch (_) {
|
||
return;
|
||
}
|
||
state.currentUser = user;
|
||
state.isAdmin = Boolean(user?.is_admin);
|
||
const editorButton = document.getElementById("runtimeEditorBtn");
|
||
editorButton?.classList.toggle("hidden", !state.isAdmin);
|
||
document.body.dataset.hockeyAdmin = state.isAdmin ? "1" : "0";
|
||
const currentAccountId = String(user?.id || "").trim();
|
||
if (!currentAccountId) return;
|
||
const previousAccountId = String(localStorage.getItem(storage.account) || "").trim();
|
||
if (previousAccountId && previousAccountId !== currentAccountId) {
|
||
// Session tokens and Agent selection belong to one WFL account only.
|
||
// Keeping them across logout/login can bind a new operator to the
|
||
// previous account's match or vMix device.
|
||
localStorage.removeItem(storage.session);
|
||
localStorage.removeItem(storage.game);
|
||
localStorage.removeItem(storage.vmixDevice);
|
||
state.selectedGameId = "";
|
||
state.selectedGame = null;
|
||
state.selectedMatchDetails = null;
|
||
stopSelectedGamePolling();
|
||
try { window.HockeyAgentRuntime?.selectDevice?.(""); } catch (_) {}
|
||
}
|
||
localStorage.setItem(storage.account, currentAccountId);
|
||
}
|
||
|
||
async function request(path, options = {}) {
|
||
const { timeoutMs = 15000, ...fetchOptions } = options;
|
||
const controller = new AbortController();
|
||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||
try {
|
||
const response = await fetch(path, {
|
||
cache: "no-store",
|
||
credentials: "same-origin",
|
||
...fetchOptions,
|
||
signal: controller.signal,
|
||
});
|
||
let payload = {};
|
||
try {
|
||
payload = await response.json();
|
||
} catch (_) {}
|
||
if (!response.ok) {
|
||
if (response.status === 401) {
|
||
window.location.assign("/");
|
||
}
|
||
const detail = payload.detail;
|
||
throw new Error(
|
||
typeof detail === "string"
|
||
? detail
|
||
: detail?.message || `HTTP ${response.status}`
|
||
);
|
||
}
|
||
return payload;
|
||
} catch (error) {
|
||
if (error?.name === "AbortError") {
|
||
// BUILD109: this helper is used for local DB/session/Agent endpoints too.
|
||
// Calling every HTTP timeout a Stat2TV timeout hides the real subsystem.
|
||
throw new Error("Превышено время ожидания ответа сервера");
|
||
}
|
||
throw error;
|
||
} finally {
|
||
clearTimeout(timeout);
|
||
}
|
||
}
|
||
|
||
function notify(message, error = false) {
|
||
if (!error && window.HOCKEY_RUNTIME_TOASTS_ENABLED === false) return;
|
||
let toast = document.querySelector(".hockey-module-toast");
|
||
if (!toast) {
|
||
toast = document.createElement("div");
|
||
toast.className = "hockey-module-toast";
|
||
document.body.appendChild(toast);
|
||
}
|
||
toast.textContent = message;
|
||
toast.classList.toggle("error", error);
|
||
toast.classList.add("show");
|
||
clearTimeout(toast._hideTimer);
|
||
toast._hideTimer = setTimeout(() => toast.classList.remove("show"), 3200);
|
||
}
|
||
|
||
function gameById(id) {
|
||
return (state.games?.items || []).find(
|
||
(item) => String(item?.external_id || item?.id || "") === String(id || "")
|
||
) || null;
|
||
}
|
||
|
||
function matchLabelById(id) {
|
||
const game = gameById(id);
|
||
if (!game) return "";
|
||
const home = game.home?.name || game.home_name || game.team1 || "";
|
||
const away = game.away?.name || game.away_name || game.team2 || "";
|
||
return [home, away].filter(Boolean).join(" — ");
|
||
}
|
||
|
||
function setMatchLoading(visible, { game = "", stage = "" } = {}) {
|
||
const overlay = state.root?.querySelector("[data-match-loading]");
|
||
if (!overlay) return;
|
||
const titleNode = overlay.querySelector("[data-match-loading-title]");
|
||
const gameNode = overlay.querySelector("[data-match-loading-game]");
|
||
const stageNode = overlay.querySelector("[data-match-loading-stage]");
|
||
const hintNode = overlay.querySelector("[data-match-loading-hint]");
|
||
if (titleNode) titleNode.textContent = t("loadingMatchTitle");
|
||
if (gameNode) {
|
||
gameNode.textContent = game || "";
|
||
gameNode.classList.toggle("hidden", !game);
|
||
}
|
||
if (stageNode) stageNode.textContent = stage || t("loadingMatchDetails");
|
||
if (hintNode) hintNode.textContent = t("loadingMatchHint");
|
||
overlay.classList.toggle("show", Boolean(visible));
|
||
overlay.setAttribute("aria-hidden", visible ? "false" : "true");
|
||
document.documentElement.classList.toggle("hockey-match-loading-active", Boolean(visible));
|
||
}
|
||
|
||
function updateMatchLoading(stage) {
|
||
const overlay = state.root?.querySelector("[data-match-loading]");
|
||
const stageNode = overlay?.querySelector("[data-match-loading-stage]");
|
||
if (stageNode) stageNode.textContent = stage || t("loadingMatchDetails");
|
||
}
|
||
|
||
function toolbarHost() {
|
||
return boot.mode === "editor"
|
||
? document.querySelector(".topbar .top-actions")
|
||
: document.querySelector(".runtime-actions");
|
||
}
|
||
|
||
function ensureShell() {
|
||
if (state.root) return;
|
||
|
||
const toolbar = toolbarHost();
|
||
if (toolbar) {
|
||
const chip = document.createElement("button");
|
||
chip.type = "button";
|
||
chip.className = "hockey-selected-chip placeholder";
|
||
chip.addEventListener("click", () => {
|
||
state.view = state.selectedTournament ? "games" : "tournaments";
|
||
setOpen(true);
|
||
renderView();
|
||
});
|
||
toolbar.prepend(chip);
|
||
state.selectedChip = chip;
|
||
}
|
||
|
||
const root = document.createElement("div");
|
||
root.className = "hockey-navigation-root";
|
||
root.innerHTML = `
|
||
<div class="hockey-navigation-backdrop"></div>
|
||
<div class="hockey-match-loading" data-match-loading aria-hidden="true">
|
||
<div class="hockey-match-loading-card" role="status" aria-live="polite" aria-atomic="true">
|
||
<div class="hockey-match-loading-spinner" aria-hidden="true"><i></i></div>
|
||
<small>STAT2TV / HOCKEY</small>
|
||
<strong data-match-loading-title>${escapeHtml(t("loadingMatchTitle"))}</strong>
|
||
<span class="hidden" data-match-loading-game></span>
|
||
<p data-match-loading-stage>${escapeHtml(t("loadingMatchDetails"))}</p>
|
||
<div class="hockey-match-loading-progress" aria-hidden="true"><i></i></div>
|
||
<em data-match-loading-hint>${escapeHtml(t("loadingMatchHint"))}</em>
|
||
</div>
|
||
</div>
|
||
<aside class="hockey-navigation-drawer" aria-label="${escapeHtml(t("title"))}">
|
||
<header class="hockey-nav-head">
|
||
<button type="button" class="hockey-nav-back hidden" data-back data-tooltip="${escapeHtml(t("back"))}">← <span>${escapeHtml(t("tournaments"))}</span></button>
|
||
<div class="hockey-nav-title">
|
||
<small>STAT2TV / HOCKEY</small>
|
||
<strong data-nav-title>${escapeHtml(t("tournaments"))}</strong>
|
||
</div>
|
||
<div class="hockey-nav-head-actions">
|
||
<div class="hockey-language-switch">
|
||
<button type="button" data-language="ru">RU</button>
|
||
<button type="button" data-language="en">EN</button>
|
||
</div>
|
||
${state.isAdmin ? `
|
||
<button type="button" class="hockey-nav-icon" data-open-connection-diagnostics data-tooltip="${escapeHtml(t("connections"))}">⌁</button>
|
||
<button type="button" class="hockey-nav-icon" data-open-settings data-tooltip="${escapeHtml(t("settings"))}">⚙</button>
|
||
` : ""}
|
||
${boot.mode !== "editor" ? `
|
||
<button type="button" class="hockey-nav-icon" data-logout data-tooltip="Выйти">⇥</button>
|
||
` : ""}
|
||
<button type="button" class="hockey-nav-icon" data-close-nav data-tooltip="${escapeHtml(t("close"))}">×</button>
|
||
</div>
|
||
</header>
|
||
|
||
${state.isAdmin ? `
|
||
<button type="button" class="hockey-connection-bar" data-connection-bar data-open-connection-diagnostics>
|
||
<span></span>
|
||
<strong>${escapeHtml(t("notConfigured"))}</strong>
|
||
</button>
|
||
` : ""}
|
||
|
||
<section class="hockey-tournament-view" data-view="tournaments">
|
||
<div class="hockey-nav-tabs">
|
||
<button type="button" data-nav-mode="today">${escapeHtml(t("today"))}</button>
|
||
<button type="button" data-nav-mode="all">${escapeHtml(t("all"))}</button>
|
||
</div>
|
||
|
||
<div class="hockey-nav-search">
|
||
<span>⌕</span>
|
||
<input type="search" data-nav-search placeholder="${escapeHtml(t("search"))}">
|
||
</div>
|
||
|
||
<div class="hockey-nav-filters">
|
||
<label>
|
||
<span>${escapeHtml(t("league"))}</span>
|
||
<select data-filter="league"></select>
|
||
</label>
|
||
<label>
|
||
<span>${escapeHtml(t("season"))}</span>
|
||
<select data-filter="season"></select>
|
||
</label>
|
||
<label>
|
||
<span>${escapeHtml(t("stage"))}</span>
|
||
<select data-filter="stage"></select>
|
||
</label>
|
||
</div>
|
||
|
||
<form class="hockey-manual-game hockey-manual-game-global" data-manual-game-form>
|
||
<div>
|
||
<strong>${escapeHtml(t("manualMatchTitle"))}</strong>
|
||
<small>${escapeHtml(t("manualMatchHint"))}</small>
|
||
</div>
|
||
<label>
|
||
<input type="text" inputmode="numeric" autocomplete="off" data-manual-game-id placeholder="${escapeHtml(t("manualMatchPlaceholder"))}">
|
||
<button type="submit">${escapeHtml(t("manualMatchButton"))}</button>
|
||
</label>
|
||
</form>
|
||
<div class="hockey-nav-summary" data-nav-summary></div>
|
||
<div class="hockey-nav-list" data-nav-list></div>
|
||
|
||
${boot.mode === "editor" ? `
|
||
<footer class="hockey-nav-footer">
|
||
<button type="button" class="btn" data-seed-import>${escapeHtml(t("importSeed"))}</button>
|
||
<button type="button" class="btn btn-accent" data-sync>${escapeHtml(t("sync"))}</button>
|
||
</footer>
|
||
` : ""}
|
||
</section>
|
||
|
||
<section class="hockey-games-view hidden" data-view="games">
|
||
<div class="hockey-selected-tournament" data-selected-tournament></div>
|
||
|
||
<div class="hockey-game-date-nav">
|
||
<button type="button" data-date-shift="-1" data-tooltip="${escapeHtml(t("datePrevious"))}">‹</button>
|
||
<label>
|
||
<span>${escapeHtml(t("gamesForDate"))}</span>
|
||
<input type="date" data-game-date value="${escapeHtml(state.gameDate)}">
|
||
</label>
|
||
<button type="button" data-date-today data-tooltip="${escapeHtml(t("dateToday"))}">${escapeHtml(t("today"))}</button>
|
||
<button type="button" data-date-shift="1" data-tooltip="${escapeHtml(t("dateNext"))}">›</button>
|
||
</div>
|
||
|
||
<div class="hockey-games-summary" data-games-summary></div>
|
||
<form class="hockey-manual-game" data-manual-game-form>
|
||
<div>
|
||
<strong>${escapeHtml(t("manualMatchTitle"))}</strong>
|
||
<small>${escapeHtml(t("manualMatchHint"))}</small>
|
||
</div>
|
||
<label>
|
||
<input type="text" inputmode="numeric" autocomplete="off" data-manual-game-id placeholder="${escapeHtml(t("manualMatchPlaceholder"))}">
|
||
<button type="submit">${escapeHtml(t("manualMatchButton"))}</button>
|
||
</label>
|
||
</form>
|
||
<div class="hockey-games-list" data-games-list></div>
|
||
|
||
${state.isAdmin ? `
|
||
<footer class="hockey-games-footer">
|
||
<button type="button" class="btn" data-games-diagnostics>${escapeHtml(t("diagnostics"))}</button>
|
||
<button type="button" class="btn" data-games-rediscover>${escapeHtml(t("rediscover"))}</button>
|
||
</footer>
|
||
` : ""}
|
||
</section>
|
||
</aside>
|
||
`;
|
||
document.body.appendChild(root);
|
||
|
||
state.root = root;
|
||
state.drawer = root.querySelector(".hockey-navigation-drawer");
|
||
state.backdrop = root.querySelector(".hockey-navigation-backdrop");
|
||
|
||
state.backdrop.addEventListener("click", () => setOpen(false));
|
||
root.querySelector("[data-close-nav]")?.addEventListener("click", () => setOpen(false));
|
||
root.querySelector("[data-back]")?.addEventListener("click", () => {
|
||
state.view = "tournaments";
|
||
renderView();
|
||
renderTournamentList();
|
||
});
|
||
root.querySelector("[data-open-settings]")?.addEventListener("click", openSettings);
|
||
root.querySelectorAll("[data-open-connection-diagnostics]").forEach((button) => {
|
||
button.addEventListener("click", () => showConnectionDiagnostics({ deep: false }));
|
||
});
|
||
root.querySelector("[data-logout]")?.addEventListener("click", () => {
|
||
const form = document.createElement("form");
|
||
form.method = "post";
|
||
form.action = "/logout";
|
||
document.body.appendChild(form);
|
||
form.submit();
|
||
});
|
||
root.querySelector("[data-sync]")?.addEventListener("click", syncTournaments);
|
||
root.querySelector("[data-seed-import]")?.addEventListener("click", importSeed);
|
||
root.querySelector("[data-games-rediscover]")?.addEventListener("click", () => syncGames(true));
|
||
root.querySelector("[data-games-diagnostics]")?.addEventListener("click", () => showConnectionDiagnostics({ deep: false }));
|
||
root.querySelectorAll("[data-manual-game-form]").forEach((form) => form.addEventListener("submit", (event) => {
|
||
event.preventDefault();
|
||
const input = event.currentTarget.querySelector("[data-manual-game-id]");
|
||
const id = String(input?.value || "").trim();
|
||
if (!/^\d+$/.test(id)) {
|
||
notify(t("manualMatchInvalid"), true);
|
||
input?.focus();
|
||
return;
|
||
}
|
||
openGame(id, { manual: true });
|
||
}));
|
||
|
||
root.querySelectorAll("[data-language]").forEach((button) => {
|
||
button.addEventListener("click", async () => {
|
||
const nextLanguage = button.dataset.language === "en" ? "en" : "ru";
|
||
if (state.language === nextLanguage) return;
|
||
state.language = nextLanguage;
|
||
localStorage.setItem(storage.language, state.language);
|
||
renderStaticLabels();
|
||
|
||
// RU/EN in the navigation is the operator's live language switch, not
|
||
// merely a cosmetic localStorage preference. Persist it server-side so
|
||
// Mapping's SQL resolver sees the same language, then immediately
|
||
// re-apply the active Mapping to this operator's vMix.
|
||
try {
|
||
await request("/api/hockey/preferences", {
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
display_language: state.language,
|
||
vmix_language: state.language,
|
||
tournament_external_id: String(state.selectedId || ""),
|
||
}),
|
||
});
|
||
const selectedAgent = currentAgentDeviceId();
|
||
const mappingRefresh = selectedAgent
|
||
? await request(`/api/hockey/agents/devices/${encodeURIComponent(selectedAgent)}/apply-mapping`, { method: "POST" })
|
||
: await request("/api/hockey/agents/apply-active-mapping", { method: "POST" });
|
||
if (mappingRefresh && mappingRefresh.ok === false && !["no_active_device", "no_match_assignment", "mapping_missing"].includes(String(mappingRefresh.reason || ""))) {
|
||
notify(`vMix Mapping: ${mappingRefresh.reason || "не удалось применить"}`, true);
|
||
}
|
||
} catch (error) {
|
||
notify(`Язык сохранён локально, но vMix не обновлён: ${error.message || error}`, true);
|
||
}
|
||
|
||
window.dispatchEvent(new CustomEvent("hockey:settings-updated", {
|
||
detail: { settings: { ui_language: state.language } },
|
||
}));
|
||
loadNavigation();
|
||
if (state.view === "games") loadGames({ autoSync: false });
|
||
if (state.selectedId) loadTeamSchedule({ force: true });
|
||
if (state.selectedGameId) reloadSelectedGameDetails();
|
||
if (state.selectedId) {
|
||
loadTournamentStandings({ sync: false });
|
||
loadTournamentStatistics({ sync: false });
|
||
}
|
||
});
|
||
});
|
||
|
||
root.querySelectorAll("[data-nav-mode]").forEach((button) => {
|
||
button.addEventListener("click", () => {
|
||
state.mode = button.dataset.navMode;
|
||
renderTournamentList();
|
||
});
|
||
});
|
||
|
||
let searchTimer = null;
|
||
root.querySelector("[data-nav-search]").addEventListener("input", (event) => {
|
||
state.search = event.target.value;
|
||
clearTimeout(searchTimer);
|
||
searchTimer = setTimeout(loadNavigation, 250);
|
||
});
|
||
|
||
root.querySelectorAll("[data-filter]").forEach((select) => {
|
||
select.addEventListener("change", () => {
|
||
state[select.dataset.filter] = select.value;
|
||
loadNavigation();
|
||
});
|
||
});
|
||
|
||
root.querySelector("[data-game-date]").addEventListener("change", (event) => {
|
||
setGameDate(event.target.value || localDateIso());
|
||
});
|
||
root.querySelectorAll("[data-date-shift]").forEach((button) => {
|
||
button.addEventListener("click", () => {
|
||
setGameDate(shiftDate(state.gameDate, Number(button.dataset.dateShift || 0)));
|
||
});
|
||
});
|
||
root.querySelector("[data-date-today]").addEventListener("click", () => {
|
||
setGameDate(localDateIso());
|
||
});
|
||
|
||
window.addEventListener("keydown", (event) => {
|
||
if (event.key === "Escape" && state.open) {
|
||
event.preventDefault();
|
||
setOpen(false);
|
||
}
|
||
});
|
||
}
|
||
|
||
function setOpen(value) {
|
||
state.open = Boolean(value);
|
||
state.root?.classList.toggle("open", state.open);
|
||
document.body.classList.toggle("hockey-navigation-open", state.open);
|
||
window.dispatchEvent(new CustomEvent("hockey:navigation-toggle", {
|
||
detail: { open: state.open },
|
||
}));
|
||
if (state.open) {
|
||
stopSelectedGamePolling();
|
||
if (!state.navigation) loadNavigation();
|
||
} else if (state.selectedGameId && !selectedGameIsFinished()) {
|
||
scheduleSelectedGamePoll(250);
|
||
}
|
||
}
|
||
|
||
function renderStaticLabels() {
|
||
if (!state.root) return;
|
||
state.root.querySelector("[data-nav-search]").placeholder = t("search");
|
||
state.root.querySelector('[data-nav-mode="today"]').textContent = t("today");
|
||
state.root.querySelector('[data-nav-mode="all"]').textContent = t("all");
|
||
state.root.querySelector('[data-filter="league"]').closest("label").querySelector("span").textContent = t("league");
|
||
state.root.querySelector('[data-filter="season"]').closest("label").querySelector("span").textContent = t("season");
|
||
state.root.querySelector('[data-filter="stage"]').closest("label").querySelector("span").textContent = t("stage");
|
||
state.root.querySelector("[data-game-date]").closest("label").querySelector("span").textContent = t("gamesForDate");
|
||
state.root.querySelector("[data-date-today]").textContent = t("today");
|
||
state.root.querySelector("[data-sync]")?.replaceChildren(t("sync"));
|
||
state.root.querySelector("[data-seed-import]")?.replaceChildren(t("importSeed"));
|
||
state.root.querySelector("[data-games-rediscover]")?.replaceChildren(t("rediscover"));
|
||
state.root.querySelector("[data-games-diagnostics]")?.replaceChildren(t("diagnostics"));
|
||
state.root.querySelectorAll("[data-manual-game-form]").forEach((manualForm) => {
|
||
manualForm.querySelector("div > strong").textContent = t("manualMatchTitle");
|
||
manualForm.querySelector("div > small").textContent = t("manualMatchHint");
|
||
manualForm.querySelector("[data-manual-game-id]").placeholder = t("manualMatchPlaceholder");
|
||
manualForm.querySelector("button[type=submit]").textContent = t("manualMatchButton");
|
||
});
|
||
state.root.querySelectorAll("[data-language]").forEach((button) => {
|
||
button.classList.toggle("active", button.dataset.language === state.language);
|
||
});
|
||
renderView();
|
||
renderTournamentList();
|
||
renderGames();
|
||
renderSelectedChip();
|
||
}
|
||
|
||
function renderView() {
|
||
if (!state.root) return;
|
||
const gamesView = state.view === "games";
|
||
state.root.querySelector('[data-view="tournaments"]').classList.toggle("hidden", gamesView);
|
||
state.root.querySelector('[data-view="games"]').classList.toggle("hidden", !gamesView);
|
||
state.root.querySelector("[data-back]").classList.toggle("hidden", !gamesView);
|
||
state.root.querySelector("[data-nav-title]").textContent = gamesView ? t("matches") : t("tournaments");
|
||
if (gamesView) renderSelectedTournament();
|
||
}
|
||
|
||
function tournamentQueryString() {
|
||
const params = new URLSearchParams({
|
||
language: state.language,
|
||
on_date: localDateIso(),
|
||
limit: "1000",
|
||
});
|
||
if (state.search.trim()) params.set("search", state.search.trim());
|
||
if (state.league) params.set("league", state.league);
|
||
if (state.season) params.set("season", state.season);
|
||
if (state.stage) params.set("stage", state.stage);
|
||
return params.toString();
|
||
}
|
||
|
||
async function loadNavigation() {
|
||
ensureShell();
|
||
state.loading = true;
|
||
renderTournamentLoading();
|
||
try {
|
||
state.navigation = await request(`/api/hockey/navigation?${tournamentQueryString()}`);
|
||
renderFilters();
|
||
renderConnection();
|
||
renderTournamentList();
|
||
await restoreSelection();
|
||
} catch (error) {
|
||
renderTournamentError(error.message);
|
||
} finally {
|
||
state.loading = false;
|
||
}
|
||
}
|
||
|
||
function renderTournamentLoading() {
|
||
const list = state.root?.querySelector("[data-nav-list]");
|
||
if (!list) return;
|
||
list.innerHTML = Array.from({ length: 6 }, () => `
|
||
<div class="hockey-nav-skeleton">
|
||
<i></i><div><b></b><span></span></div>
|
||
</div>
|
||
`).join("");
|
||
}
|
||
|
||
function renderTournamentError(message) {
|
||
const list = state.root?.querySelector("[data-nav-list]");
|
||
if (!list) return;
|
||
list.innerHTML = `
|
||
<div class="hockey-nav-empty error">
|
||
<strong>Stat2TV</strong>
|
||
<p>${escapeHtml(message)}</p>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderFilters() {
|
||
const filters = state.navigation?.filters;
|
||
if (!filters) return;
|
||
const allOption = `<option value="">${escapeHtml(t("allValues"))}</option>`;
|
||
|
||
const league = state.root.querySelector('[data-filter="league"]');
|
||
league.innerHTML = allOption + filters.leagues.map((item) => `
|
||
<option value="${escapeHtml(item.value)}">${escapeHtml(item.label)}</option>
|
||
`).join("");
|
||
league.value = state.league;
|
||
|
||
const season = state.root.querySelector('[data-filter="season"]');
|
||
season.innerHTML = allOption + filters.seasons.map((item) => `
|
||
<option value="${escapeHtml(item)}">${escapeHtml(item)}</option>
|
||
`).join("");
|
||
season.value = state.season;
|
||
|
||
const stage = state.root.querySelector('[data-filter="stage"]');
|
||
stage.innerHTML = allOption + filters.stages.map((item) => `
|
||
<option value="${escapeHtml(item.value)}">${escapeHtml(item.label)}</option>
|
||
`).join("");
|
||
stage.value = state.stage;
|
||
}
|
||
|
||
function renderConnection() {
|
||
if (!state.isAdmin) return;
|
||
const meta = state.navigation?.meta || {};
|
||
const bar = state.root?.querySelector("[data-connection-bar]");
|
||
if (!bar) return;
|
||
const configured = Boolean(meta.credentials_configured);
|
||
bar.classList.toggle("connected", configured);
|
||
bar.querySelector("strong").textContent = configured
|
||
? (state.language === "en" ? "Stat2TV configured" : "Stat2TV настроен")
|
||
: (state.language === "en" ? "Stat2TV credentials missing" : "Stat2TV: нет данных авторизации");
|
||
const last = meta.last_sync;
|
||
bar.dataset.tooltip = last?.source === "stat2tv" && last?.status === "success"
|
||
? `${t("sourceApi")} · ${last.item_count || 0} · ${t("connections")}`
|
||
: `${t("connections")} · ${t("adminOnly")}`;
|
||
}
|
||
|
||
function tournamentListForMode() {
|
||
const nav = state.navigation || {};
|
||
if (state.mode === "all") return nav.items || [];
|
||
return (nav.today?.length ? nav.today : nav.recommended) || [];
|
||
}
|
||
|
||
function groupTournaments(items) {
|
||
const groups = new Map();
|
||
items.forEach((item) => {
|
||
const key = `${item.level}|${item.league}`;
|
||
if (!groups.has(key)) groups.set(key, { level: item.level, label: item.league, items: [] });
|
||
groups.get(key).items.push(item);
|
||
});
|
||
return [...groups.values()];
|
||
}
|
||
|
||
function renderTournamentList() {
|
||
if (!state.root || !state.navigation) return;
|
||
state.root.querySelectorAll("[data-nav-mode]").forEach((button) => {
|
||
button.classList.toggle("active", button.dataset.navMode === state.mode);
|
||
});
|
||
|
||
const meta = state.navigation.meta;
|
||
const summary = state.root.querySelector("[data-nav-summary]");
|
||
if (state.mode === "today" && !meta.today_count) {
|
||
summary.innerHTML = `
|
||
<span>${escapeHtml(t("offSeason"))}</span>
|
||
<strong>${escapeHtml(t("fallback"))}: ${escapeHtml(meta.latest_season || "—")}</strong>
|
||
`;
|
||
} else {
|
||
summary.innerHTML = `
|
||
<span>${escapeHtml(state.mode === "today" ? t("today") : t("all"))}</span>
|
||
<strong>${state.mode === "today" ? meta.today_count : meta.shown}</strong>
|
||
`;
|
||
}
|
||
|
||
const items = tournamentListForMode();
|
||
const list = state.root.querySelector("[data-nav-list]");
|
||
if (!items.length) {
|
||
list.innerHTML = `
|
||
<div class="hockey-nav-empty">
|
||
<strong>${escapeHtml(t("noItems"))}</strong>
|
||
<p>${escapeHtml(state.mode === "today" ? t("noToday") : "")}</p>
|
||
</div>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
list.innerHTML = groupTournaments(items).map((group) => `
|
||
<section class="hockey-league-group">
|
||
<header>
|
||
<span>${escapeHtml(group.level.toUpperCase())}</span>
|
||
<strong>${escapeHtml(group.label)}</strong>
|
||
<i>${group.items.length}</i>
|
||
</header>
|
||
<div>${group.items.map(tournamentCard).join("")}</div>
|
||
</section>
|
||
`).join("");
|
||
|
||
list.querySelectorAll("[data-tournament-id]").forEach((button) => {
|
||
button.addEventListener("click", () => openTournament(button.dataset.tournamentId));
|
||
});
|
||
}
|
||
|
||
function tournamentCard(item) {
|
||
const selected = String(item.id) === String(state.selectedId);
|
||
const dates = item.start_date && item.end_date
|
||
? `${item.start_date} — ${item.end_date}`
|
||
: item.start_date || item.end_date || "—";
|
||
return `
|
||
<button type="button"
|
||
class="hockey-tournament-card ${selected ? "selected" : ""}"
|
||
data-tournament-id="${escapeHtml(item.id)}">
|
||
<span class="hockey-tournament-stage">${escapeHtml(item.stage)}</span>
|
||
<strong>${escapeHtml(item.name || item.full_name)}</strong>
|
||
<small>
|
||
<b>${escapeHtml(item.season || "—")}</b>
|
||
<span>${escapeHtml(dates)}</span>
|
||
</small>
|
||
<i>›</i>
|
||
</button>
|
||
`;
|
||
}
|
||
|
||
async function restoreSelection() {
|
||
if (!state.selectedId) return;
|
||
try {
|
||
state.selectedTournament = await request(
|
||
`/api/hockey/tournaments/${encodeURIComponent(state.selectedId)}?language=${state.language}`
|
||
);
|
||
applyTournamentData();
|
||
renderSelectedChip();
|
||
await loadTournamentStandings({ sync: false });
|
||
await loadTournamentStatistics({ sync: false });
|
||
} catch (_) {}
|
||
if (state.selectedGameId) {
|
||
try {
|
||
const loaded = await request(
|
||
`/api/hockey/games/${encodeURIComponent(state.selectedGameId)}/details?language=${state.language}`
|
||
);
|
||
const restoredTournamentId = String(loaded.game?.tournament_external_id || "");
|
||
if (restoredTournamentId && restoredTournamentId !== String(state.selectedId || "")) {
|
||
state.selectedId = restoredTournamentId;
|
||
localStorage.setItem(storage.tournament, state.selectedId);
|
||
try {
|
||
state.selectedTournament = await request(
|
||
`/api/hockey/tournaments/${encodeURIComponent(state.selectedId)}?language=${state.language}`
|
||
);
|
||
} catch (_) {
|
||
state.selectedTournament = null;
|
||
}
|
||
state.standings = null;
|
||
state.tournamentStatistics = null;
|
||
state.games = null;
|
||
state.view = "games";
|
||
applyTournamentData();
|
||
}
|
||
state.selectedGame = loaded.game;
|
||
state.selectedMatchDetails = loaded.details || null;
|
||
applyGameData();
|
||
await loadTournamentStandings({ sync: false });
|
||
renderSelectedChip();
|
||
startSelectedGamePolling();
|
||
} catch (_) {
|
||
try {
|
||
state.selectedGame = await request(
|
||
`/api/hockey/games/${encodeURIComponent(state.selectedGameId)}?language=${state.language}`
|
||
);
|
||
state.selectedMatchDetails = null;
|
||
applyGameData();
|
||
await loadTournamentStandings({ sync: false });
|
||
renderSelectedChip();
|
||
startSelectedGamePolling();
|
||
} catch (_) {}
|
||
}
|
||
}
|
||
}
|
||
|
||
async function reloadSelectedGameDetails() {
|
||
if (!state.selectedGameId) return;
|
||
try {
|
||
const loaded = await request(
|
||
`/api/hockey/games/${encodeURIComponent(state.selectedGameId)}/details?language=${state.language}`
|
||
);
|
||
state.selectedGame = loaded.game;
|
||
state.selectedMatchDetails = loaded.details || null;
|
||
applyGameData();
|
||
renderSelectedChip();
|
||
} catch (_) {}
|
||
}
|
||
|
||
async function openTournament(id) {
|
||
try {
|
||
if (String(id) !== String(state.selectedId)) {
|
||
stopSelectedGamePolling();
|
||
state.selectedGame = null;
|
||
state.selectedMatchDetails = null;
|
||
state.standings = null;
|
||
state.tournamentStatistics = null;
|
||
state.selectedGameId = "";
|
||
localStorage.removeItem(storage.game);
|
||
const oldToken = localStorage.getItem(storage.session);
|
||
if (oldToken) {
|
||
request(`/api/hockey/sessions/${encodeURIComponent(oldToken)}`, {
|
||
method: "DELETE",
|
||
}).catch(() => {});
|
||
localStorage.removeItem(storage.session);
|
||
}
|
||
}
|
||
state.selectedTournament = await request(
|
||
`/api/hockey/tournaments/${encodeURIComponent(id)}?language=${state.language}`
|
||
);
|
||
state.selectedId = String(id);
|
||
localStorage.setItem(storage.tournament, state.selectedId);
|
||
state.games = null;
|
||
state.teamSchedule = null;
|
||
state.teamScheduleTournamentId = "";
|
||
state.view = "games";
|
||
applyTournamentData();
|
||
renderSelectedChip();
|
||
renderView();
|
||
await loadGames({ autoSync: true });
|
||
await loadTournamentStandings({ sync: true });
|
||
await loadTournamentStatistics({ sync: true });
|
||
await loadStatLabels();
|
||
} catch (error) {
|
||
notify(error.message, true);
|
||
}
|
||
}
|
||
|
||
function schedulePayload() {
|
||
const current = state.games && typeof state.games === "object"
|
||
? state.games
|
||
: { meta: {}, items: [] };
|
||
return {
|
||
...current,
|
||
meta: {
|
||
...(current.meta || {}),
|
||
date: state.gameDate,
|
||
tournament_external_id: state.selectedId || "",
|
||
count: Array.isArray(current.items) ? current.items.length : 0,
|
||
},
|
||
items: Array.isArray(current.items) ? current.items : [],
|
||
selected_date: state.gameDate,
|
||
};
|
||
}
|
||
|
||
function applyGamesData() {
|
||
const schedule = schedulePayload();
|
||
window.UIBuilderRuntime?.patchData?.({ hockey: { schedule } });
|
||
window.dispatchEvent(new CustomEvent("hockey:schedule-updated", {
|
||
detail: { schedule, language: state.language },
|
||
}));
|
||
}
|
||
|
||
function applyTournamentData() {
|
||
if (!state.selectedTournament) return;
|
||
window.UIBuilderRuntime?.patchData?.({
|
||
hockey: {
|
||
selected_tournament: state.selectedTournament,
|
||
schedule: schedulePayload(),
|
||
team_schedule: state.teamSchedule || { meta: {}, items: [] },
|
||
standings: state.standings,
|
||
tournament_statistics: state.tournamentStatistics,
|
||
stat_labels: state.statLabels,
|
||
language: { display: state.language, available: ["ru", "en"] },
|
||
},
|
||
});
|
||
window.dispatchEvent(new CustomEvent("hockey:tournament-selected", {
|
||
detail: { tournament: state.selectedTournament, language: state.language },
|
||
}));
|
||
}
|
||
|
||
function standingsUrl({ sync = false } = {}) {
|
||
const params = new URLSearchParams({ language: state.language });
|
||
if (state.selectedGameId) params.set("game_id", state.selectedGameId);
|
||
return `/api/hockey/tournaments/${encodeURIComponent(state.selectedId)}/standings`
|
||
+ `${sync ? "/sync" : ""}?${params.toString()}`;
|
||
}
|
||
|
||
function applyStandingsData() {
|
||
window.UIBuilderRuntime?.patchData?.({
|
||
hockey: { standings: state.standings },
|
||
});
|
||
}
|
||
|
||
async function loadTournamentStandings({ sync = false } = {}) {
|
||
if (!state.selectedId) return;
|
||
try {
|
||
const loaded = await request(
|
||
standingsUrl({ sync }),
|
||
sync ? { method: "POST", timeoutMs: 30000 } : {}
|
||
);
|
||
state.standings = loaded.standings || loaded;
|
||
applyStandingsData();
|
||
} catch (error) {
|
||
if (sync) {
|
||
try {
|
||
const cached = await request(standingsUrl({ sync: false }));
|
||
state.standings = cached.standings || cached;
|
||
applyStandingsData();
|
||
} catch (_) {}
|
||
}
|
||
}
|
||
}
|
||
|
||
function tournamentStatisticsUrl({ sync = false, force = false } = {}) {
|
||
const params = new URLSearchParams({ language: state.language });
|
||
if (force) params.set("force", "true");
|
||
return `/api/hockey/tournaments/${encodeURIComponent(state.selectedId)}/statistics`
|
||
+ `${sync ? "/sync" : ""}?${params.toString()}`;
|
||
}
|
||
|
||
function applyStatLabelsData() {
|
||
window.UIBuilderRuntime?.patchData?.({
|
||
hockey: { stat_labels: state.statLabels },
|
||
});
|
||
}
|
||
|
||
async function loadStatLabels({ force = false } = {}) {
|
||
if (!state.selectedId) return;
|
||
try {
|
||
const suffix = force ? "?force=true" : "";
|
||
state.statLabels = await request(
|
||
`/api/hockey/tournaments/${encodeURIComponent(state.selectedId)}/navigation-labels${suffix}`
|
||
);
|
||
applyStatLabelsData();
|
||
} catch (_) {
|
||
state.statLabels = null;
|
||
applyStatLabelsData();
|
||
}
|
||
}
|
||
|
||
function applyTournamentStatisticsData() {
|
||
window.UIBuilderRuntime?.patchData?.({
|
||
hockey: { tournament_statistics: state.tournamentStatistics },
|
||
});
|
||
}
|
||
|
||
async function loadTournamentStatistics({ sync = false, force = false } = {}) {
|
||
if (!state.selectedId) return;
|
||
try {
|
||
const loaded = await request(
|
||
tournamentStatisticsUrl({ sync, force }),
|
||
sync ? { method: "POST", timeoutMs: 30000 } : {}
|
||
);
|
||
state.tournamentStatistics = loaded.statistics || loaded;
|
||
applyTournamentStatisticsData();
|
||
} catch (_) {
|
||
if (sync) {
|
||
try {
|
||
state.tournamentStatistics = await request(
|
||
tournamentStatisticsUrl({ sync: false })
|
||
);
|
||
applyTournamentStatisticsData();
|
||
} catch (_) {}
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
function renderSelectedTournament() {
|
||
const host = state.root?.querySelector("[data-selected-tournament]");
|
||
if (!host) return;
|
||
const item = state.selectedTournament;
|
||
if (!item) {
|
||
host.innerHTML = "";
|
||
return;
|
||
}
|
||
host.innerHTML = `
|
||
<div>
|
||
<span>${escapeHtml(item.league)} · ${escapeHtml(item.season || "")}</span>
|
||
<strong>${escapeHtml(item.name || item.full_name || "")}</strong>
|
||
<small>${escapeHtml(item.stage || "")}</small>
|
||
</div>
|
||
<button type="button" data-change-tournament>${escapeHtml(t("changeTournament"))}</button>
|
||
`;
|
||
host.querySelector("[data-change-tournament]")?.addEventListener("click", () => {
|
||
state.view = "tournaments";
|
||
renderView();
|
||
renderTournamentList();
|
||
});
|
||
}
|
||
|
||
function setGameDate(value) {
|
||
state.gameDate = value || localDateIso();
|
||
localStorage.setItem(storage.gameDate, state.gameDate);
|
||
const input = state.root?.querySelector("[data-game-date]");
|
||
if (input) input.value = state.gameDate;
|
||
state.games = null;
|
||
applyGamesData();
|
||
loadGames({ autoSync: false });
|
||
}
|
||
|
||
function gamesUrl() {
|
||
return `/api/hockey/tournaments/${encodeURIComponent(state.selectedId)}/games`
|
||
+ `?on_date=${encodeURIComponent(state.gameDate)}`
|
||
+ `&language=${encodeURIComponent(state.language)}`;
|
||
}
|
||
|
||
function allDatesGamesUrl() {
|
||
return `/api/hockey/tournaments/${encodeURIComponent(state.selectedId)}/games`
|
||
+ `?on_date=${encodeURIComponent(state.gameDate)}`
|
||
+ `&language=${encodeURIComponent(state.language)}`
|
||
+ `&all_dates=true`;
|
||
}
|
||
|
||
function applyTeamScheduleData() {
|
||
const current = state.teamSchedule && typeof state.teamSchedule === "object"
|
||
? state.teamSchedule
|
||
: { meta: {}, items: [] };
|
||
const teamSchedule = {
|
||
...current,
|
||
meta: {
|
||
...(current.meta || {}),
|
||
tournament_external_id: state.selectedId || "",
|
||
count: Array.isArray(current.items) ? current.items.length : 0,
|
||
},
|
||
items: Array.isArray(current.items) ? current.items : [],
|
||
};
|
||
window.UIBuilderRuntime?.patchData?.({ hockey: { team_schedule: teamSchedule } });
|
||
window.dispatchEvent(new CustomEvent("hockey:team-schedule-updated", {
|
||
detail: { schedule: teamSchedule, language: state.language },
|
||
}));
|
||
}
|
||
|
||
async function loadTeamSchedule({ force = false } = {}) {
|
||
if (!state.selectedId) return;
|
||
if (
|
||
!force
|
||
&& state.teamSchedule
|
||
&& state.teamScheduleTournamentId === String(state.selectedId)
|
||
) {
|
||
applyTeamScheduleData();
|
||
return;
|
||
}
|
||
try {
|
||
state.teamSchedule = await request(allDatesGamesUrl());
|
||
state.teamScheduleTournamentId = String(state.selectedId);
|
||
} catch (_) {
|
||
state.teamSchedule = state.teamSchedule || { meta: {}, items: [] };
|
||
}
|
||
applyTeamScheduleData();
|
||
}
|
||
|
||
async function loadGames({ autoSync = false } = {}) {
|
||
if (!state.selectedId) return;
|
||
state.loading = true;
|
||
renderGamesLoading(t("loadingGames"));
|
||
try {
|
||
state.games = await request(gamesUrl());
|
||
applyGamesData();
|
||
renderGames();
|
||
await loadTeamSchedule();
|
||
const syncKey = String(state.selectedId);
|
||
if (
|
||
autoSync
|
||
&& state.games.meta.credentials_configured
|
||
&& !state.games.meta.schedule_loaded
|
||
&& !state.gamesAutoSyncAttempted.has(syncKey)
|
||
&& !state.scheduleSyncBusy
|
||
) {
|
||
state.gamesAutoSyncAttempted.add(syncKey);
|
||
await syncGames(false, { silentSuccess: true });
|
||
}
|
||
} catch (error) {
|
||
state.games = { meta: { date: state.gameDate, tournament_external_id: state.selectedId, count: 0 }, items: [] };
|
||
applyGamesData();
|
||
renderGamesError(error.message);
|
||
} finally {
|
||
state.loading = false;
|
||
}
|
||
}
|
||
|
||
function renderGamesLoading(message) {
|
||
const list = state.root?.querySelector("[data-games-list]");
|
||
const summary = state.root?.querySelector("[data-games-summary]");
|
||
if (summary) summary.innerHTML = `<span>${escapeHtml(message)}</span><i class="hockey-spinner"></i>`;
|
||
if (list) {
|
||
list.innerHTML = Array.from({ length: 4 }, () => `
|
||
<div class="hockey-game-skeleton">
|
||
<span></span><b></b><b></b><i></i>
|
||
</div>
|
||
`).join("");
|
||
}
|
||
}
|
||
|
||
function renderGamesError(message) {
|
||
const list = state.root?.querySelector("[data-games-list]");
|
||
const summary = state.root?.querySelector("[data-games-summary]");
|
||
if (summary) summary.innerHTML = `<span>${escapeHtml(t("syncFailed"))}</span>`;
|
||
if (list) {
|
||
list.innerHTML = `
|
||
<div class="hockey-games-empty error">
|
||
<strong>${escapeHtml(t("syncFailed"))}</strong>
|
||
<p>${escapeHtml(message)}</p>
|
||
<small>${escapeHtml(state.isAdmin ? `${t("diagnostics")} · ${t("adminOnly")}` : "")}</small>
|
||
</div>
|
||
`;
|
||
}
|
||
}
|
||
|
||
function matchOpenErrorMarkup() {
|
||
if (!state.lastGameOpenError) return "";
|
||
const diagnosticButton = state.isAdmin
|
||
? `<button type="button" class="btn" data-match-error-diagnostics>${escapeHtml(t("connections"))}</button>`
|
||
: "";
|
||
return `
|
||
<div class="hockey-games-empty error hockey-match-open-error">
|
||
<strong>${escapeHtml(state.language === "en" ? "Failed to open game" : "Не удалось открыть матч")}</strong>
|
||
<p>${escapeHtml(state.lastGameOpenError)}</p>
|
||
${diagnosticButton}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function bindMatchErrorDiagnostics(root) {
|
||
root?.querySelector("[data-match-error-diagnostics]")?.addEventListener("click", () => {
|
||
showConnectionDiagnostics({ deep: true });
|
||
});
|
||
}
|
||
|
||
function renderGames() {
|
||
if (!state.root || state.view !== "games") return;
|
||
renderSelectedTournament();
|
||
const input = state.root.querySelector("[data-game-date]");
|
||
input.value = state.gameDate;
|
||
const summary = state.root.querySelector("[data-games-summary]");
|
||
const list = state.root.querySelector("[data-games-list]");
|
||
const items = state.games?.items || [];
|
||
const meta = state.games?.meta || {};
|
||
|
||
summary.innerHTML = `
|
||
<div>
|
||
<span>${escapeHtml(formatDate(state.gameDate))}</span>
|
||
<strong>${items.length}</strong>
|
||
</div>
|
||
<small class="${meta.endpoint_template ? "found" : ""}">
|
||
${escapeHtml(meta.endpoint_template ? t("endpointFound") : t("endpointNotFound"))}
|
||
</small>
|
||
`;
|
||
|
||
if (!items.length) {
|
||
list.innerHTML = `${matchOpenErrorMarkup()}
|
||
<div class="hockey-games-empty">
|
||
<strong>${escapeHtml(meta.last_synced_at ? t("noGames") : t("noGamesLoaded"))}</strong>
|
||
<p>${escapeHtml(formatDate(state.gameDate))}</p>
|
||
<small>${escapeHtml(
|
||
meta.schedule_loaded
|
||
? t("scheduleCached")
|
||
: meta.credentials_configured
|
||
? t("loadingGames")
|
||
: state.isAdmin
|
||
? t("notConfigured")
|
||
: t("noGamesLoaded")
|
||
)}</small>
|
||
</div>
|
||
`;
|
||
bindMatchErrorDiagnostics(list);
|
||
return;
|
||
}
|
||
|
||
list.innerHTML = `${matchOpenErrorMarkup()}${items.map(gameCard).join("")}`;
|
||
bindMatchErrorDiagnostics(list);
|
||
list.querySelectorAll("[data-game-id]").forEach((button) => {
|
||
button.addEventListener("click", () => openGame(button.dataset.gameId));
|
||
});
|
||
}
|
||
|
||
function gameFinishLabel(game) {
|
||
const direct = String(game?.score?.finish_label || "").trim();
|
||
if (direct) return direct;
|
||
const finishType = String(game?.score?.finish_type || "").toUpperCase();
|
||
if (finishType === "SO") return t("finishShootout");
|
||
if (finishType === "OT") return t("finishOvertime");
|
||
return "";
|
||
}
|
||
|
||
function gameStatusLabel(game) {
|
||
if (game?.is_finished || game?.status === "finished") {
|
||
const finish = gameFinishLabel(game);
|
||
return `${t("statusFinished")}${finish ? ` · ${finish}` : ""}`;
|
||
}
|
||
if (game?.status === "live") return t("statusLive");
|
||
if (game?.status === "scheduled") return t("statusScheduled");
|
||
return game?.status || "";
|
||
}
|
||
|
||
function gameCard(game) {
|
||
const selected = String(game.external_id) === String(state.selectedGameId);
|
||
const homeName = game.home?.name || t("unknownTeams");
|
||
const awayName = game.away?.name || t("unknownTeams");
|
||
const showScore = (
|
||
game.is_finished
|
||
|| game.status === "live"
|
||
|| Number(game.home?.score)
|
||
|| Number(game.away?.score)
|
||
);
|
||
const round = game.round?.name || "";
|
||
const series = game.score?.series || "";
|
||
const status = gameStatusLabel(game);
|
||
|
||
return `
|
||
<button type="button"
|
||
class="hockey-game-card ${selected ? "selected" : ""} ${game.status === "live" ? "live" : ""} ${game.is_finished ? "finished" : ""}"
|
||
data-game-id="${escapeHtml(game.external_id)}">
|
||
<header>
|
||
<span>${escapeHtml(game.time || "—")}</span>
|
||
<small>
|
||
${game.game_number ? `#${escapeHtml(game.game_number)}` : ""}
|
||
${round ? ` · ${escapeHtml(round)}` : ""}
|
||
</small>
|
||
${status ? `<i>${escapeHtml(status)}</i>` : ""}
|
||
</header>
|
||
<div class="hockey-game-team">
|
||
<strong>${escapeHtml(homeName)}</strong>
|
||
${showScore ? `<b>${Number(game.home?.score || 0)}</b>` : `<b>—</b>`}
|
||
</div>
|
||
<div class="hockey-game-team">
|
||
<strong>${escapeHtml(awayName)}</strong>
|
||
${showScore ? `<b>${Number(game.away?.score || 0)}</b>` : `<b>—</b>`}
|
||
</div>
|
||
<footer>
|
||
<span>${escapeHtml(game.arena || "")}</span>
|
||
${series ? `<em>${escapeHtml(t("series"))}: ${escapeHtml(series)}</em>` : ""}
|
||
<i>${selected ? "✓" : "›"}</i>
|
||
</footer>
|
||
</button>
|
||
`;
|
||
}
|
||
|
||
async function syncGames(forceDiscovery, { silentSuccess = false } = {}) {
|
||
if (!state.selectedId || state.scheduleSyncBusy) return;
|
||
state.scheduleSyncBusy = true;
|
||
const rediscover = state.root?.querySelector("[data-games-rediscover]");
|
||
if (rediscover) rediscover.disabled = true;
|
||
renderGamesLoading(forceDiscovery ? t("discovering") : t("loadingGames"));
|
||
try {
|
||
const result = await request(
|
||
`/api/hockey/tournaments/${encodeURIComponent(state.selectedId)}/games/sync`
|
||
+ `?on_date=${encodeURIComponent(state.gameDate)}`
|
||
+ `&force_discovery=${forceDiscovery ? "true" : "false"}`,
|
||
{ method: "POST" }
|
||
);
|
||
if (!silentSuccess) {
|
||
const visibleCount = Number.isFinite(Number(result.selected_date_count))
|
||
? Number(result.selected_date_count)
|
||
: Number(result.count || 0);
|
||
const fallbackLabel = result.fallback_used
|
||
? (state.language === "en" ? " · tournament API fallback" : " · резервный API турнира")
|
||
: "";
|
||
notify(`${t("gamesLoaded")}: ${visibleCount} · ${result.endpoint}${fallbackLabel}`);
|
||
}
|
||
state.gamesAutoSyncAttempted.add(String(state.selectedId));
|
||
state.games = await request(gamesUrl());
|
||
applyGamesData();
|
||
renderGames();
|
||
await loadTeamSchedule({ force: true });
|
||
} catch (error) {
|
||
state.gamesAutoSyncAttempted.delete(String(state.selectedId));
|
||
renderGamesError(error.message);
|
||
notify(error.message, true);
|
||
} finally {
|
||
state.scheduleSyncBusy = false;
|
||
if (rediscover) rediscover.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function enrichOpenedGame(id, tournamentId, generation) {
|
||
try {
|
||
const loaded = await request(
|
||
`/api/hockey/games/${encodeURIComponent(id)}/details/sync`
|
||
+ `?tournament_id=${encodeURIComponent(tournamentId)}`
|
||
+ `&language=${encodeURIComponent(state.language)}`,
|
||
{ method: "POST", timeoutMs: 16000 }
|
||
);
|
||
if (
|
||
generation !== state.gameOpenGeneration
|
||
|| String(state.selectedGameId || "") !== String(id)
|
||
) return;
|
||
if (loaded?.game) state.selectedGame = loaded.game;
|
||
state.selectedMatchDetails = loaded?.details || state.selectedMatchDetails || null;
|
||
applyGameData();
|
||
renderSelectedChip();
|
||
renderGames();
|
||
if (loaded?.details_sync_failed || loaded?.degraded) {
|
||
console.warn("[Hockey] Match opened with cached/schedule data; full details are not available yet", loaded?.warnings || []);
|
||
}
|
||
} catch (error) {
|
||
// Full match JSON, rosters, shots and directories are enrichment data.
|
||
// Their absence must never undo a match already selected by the operator.
|
||
console.warn(`[Hockey] Optional match details sync failed for ${id}:`, error);
|
||
}
|
||
}
|
||
|
||
async function openGame(id, { manual = false } = {}) {
|
||
if (state.gameOpenBusy) return;
|
||
state.lastAttemptedGameId = String(id || "");
|
||
state.lastGameOpenError = "";
|
||
state.gameOpenBusy = true;
|
||
const generation = ++state.gameOpenGeneration;
|
||
const loadingGame = matchLabelById(id) || `${t("gameNumber")} ID ${id}`;
|
||
setMatchLoading(true, { game: loadingGame, stage: t("loadingMatchDetails") });
|
||
try {
|
||
// Match selection itself depends only on the local/cached schedule row.
|
||
// Full Stat2TV match JSON is optional enrichment and is loaded later.
|
||
const scheduleGame = gameById(id);
|
||
if (manual) {
|
||
// Manual ID entry still performs global tournament discovery because the
|
||
// game may not exist in the local schedule yet.
|
||
const discovered = await request(
|
||
`/api/hockey/games/manual/${encodeURIComponent(id)}?language=${encodeURIComponent(state.language)}`,
|
||
{ method: "POST", timeoutMs: 60000 }
|
||
);
|
||
state.selectedGame = discovered.game;
|
||
state.selectedMatchDetails = discovered.details || null;
|
||
} else if (scheduleGame) {
|
||
// BUILD109: the card the operator clicked is already a complete local
|
||
// schedule snapshot. Opening the workspace must be immediate; cached
|
||
// details/full match data are optional enrichment and are loaded below.
|
||
state.selectedGame = scheduleGame;
|
||
state.selectedMatchDetails = null;
|
||
} else {
|
||
// Defensive fallback for a stale DOM/list state. This path is local DB
|
||
// only and is not used for the normal visible match-card click.
|
||
state.selectedGame = await request(
|
||
`/api/hockey/games/${encodeURIComponent(id)}?language=${encodeURIComponent(state.language)}`,
|
||
{ timeoutMs: 5000 }
|
||
);
|
||
state.selectedMatchDetails = null;
|
||
}
|
||
|
||
const actualTournamentId = String(
|
||
state.selectedGame?.tournament_external_id || state.selectedId || ""
|
||
).trim();
|
||
if (!actualTournamentId) throw new Error("Не удалось определить турнир выбранного матча");
|
||
|
||
if (actualTournamentId !== String(state.selectedId || "")) {
|
||
state.selectedId = actualTournamentId;
|
||
localStorage.setItem(storage.tournament, state.selectedId);
|
||
try {
|
||
state.selectedTournament = await request(
|
||
`/api/hockey/tournaments/${encodeURIComponent(state.selectedId)}?language=${encodeURIComponent(state.language)}`,
|
||
{ timeoutMs: 5000 }
|
||
);
|
||
} catch (_) {
|
||
state.selectedTournament = state.selectedTournament || null;
|
||
}
|
||
state.standings = null;
|
||
state.tournamentStatistics = null;
|
||
state.games = null;
|
||
state.teamSchedule = null;
|
||
state.teamScheduleTournamentId = "";
|
||
applyTournamentData();
|
||
}
|
||
|
||
if (manual && state.selectedGame?.tournament_external_id) {
|
||
state.selectedId = String(state.selectedGame.tournament_external_id);
|
||
localStorage.setItem(storage.tournament, state.selectedId);
|
||
}
|
||
|
||
updateMatchLoading(t("loadingMatchSession"));
|
||
const previousToken = localStorage.getItem(storage.session);
|
||
if (previousToken) {
|
||
try {
|
||
const oldSession = await request(`/api/hockey/sessions/${encodeURIComponent(previousToken)}`);
|
||
if (String(oldSession.game_external_id || "") !== String(id)) {
|
||
await request(`/api/hockey/sessions/${encodeURIComponent(previousToken)}`, { method: "DELETE" });
|
||
localStorage.removeItem(storage.session);
|
||
}
|
||
} catch (_) {
|
||
localStorage.removeItem(storage.session);
|
||
}
|
||
}
|
||
|
||
let session = null;
|
||
const selectedDeviceId = currentAgentDeviceId();
|
||
const sessionPayload = {
|
||
tournament_external_id: state.selectedId,
|
||
game_external_id: String(id),
|
||
display_language: state.language,
|
||
vmix_language: state.language,
|
||
vmix_device_id: selectedDeviceId,
|
||
replace_existing: true,
|
||
};
|
||
try {
|
||
session = await request("/api/hockey/sessions", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(sessionPayload),
|
||
});
|
||
} catch (sessionError) {
|
||
// Retry the operator session without Agent if the browser carries a stale device.
|
||
if (selectedDeviceId) {
|
||
localStorage.removeItem(storage.vmixDevice);
|
||
try { window.HockeyAgentRuntime?.selectDevice?.(""); } catch (_) {}
|
||
session = await request("/api/hockey/sessions", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ ...sessionPayload, vmix_device_id: "" }),
|
||
});
|
||
} else {
|
||
throw sessionError;
|
||
}
|
||
}
|
||
|
||
state.lastGameOpenError = "";
|
||
state.selectedGameId = String(id);
|
||
localStorage.setItem(storage.game, state.selectedGameId);
|
||
if (session?.token) localStorage.setItem(storage.session, session.token);
|
||
else localStorage.removeItem(storage.session);
|
||
|
||
const selectedGameDate = String(state.selectedGame?.date || "").trim();
|
||
if (selectedGameDate) {
|
||
state.gameDate = selectedGameDate;
|
||
localStorage.setItem(storage.gameDate, state.gameDate);
|
||
}
|
||
|
||
// Publish the selected match immediately. Do not wait for lineups, shots,
|
||
// standings or any remote Stat2TV dependency before the operator can work.
|
||
applyGameData(session);
|
||
renderSelectedChip();
|
||
startSelectedGamePolling();
|
||
setOpen(false);
|
||
|
||
updateMatchLoading(t("loadingMatchFinishing"));
|
||
loadTournamentStandings({ sync: false }).catch(() => {});
|
||
if (selectedGameDate) loadGames({ autoSync: false }).catch(() => {});
|
||
|
||
const playersCount = (state.selectedGame?.home?.players?.length || 0)
|
||
+ (state.selectedGame?.away?.players?.length || 0);
|
||
const refereesCount = state.selectedGame?.referees?.length || 0;
|
||
const loadedText = state.selectedMatchDetails?.loaded
|
||
? ` · ${playersCount} ${state.language === "en" ? "players" : "игроков"}`
|
||
+ ` · ${refereesCount} ${state.language === "en" ? "officials" : "судей"}`
|
||
: "";
|
||
const homeName = state.selectedGame?.home?.name || "—";
|
||
const awayName = state.selectedGame?.away?.name || "—";
|
||
notify(`${t("gameOpened")}: ${homeName} — ${awayName}${loadedText}`);
|
||
if (manual) state.root?.querySelectorAll("[data-manual-game-id]").forEach((manualInput) => { manualInput.value = ""; });
|
||
|
||
// Enrich in the background. If a particular match has no full JSON card,
|
||
// the already-open schedule match remains active and usable.
|
||
void enrichOpenedGame(String(id), state.selectedId, generation);
|
||
} catch (error) {
|
||
state.lastGameOpenError = String(error?.message || error || "Неизвестная ошибка");
|
||
if (state.view === "games") renderGames();
|
||
notify(state.lastGameOpenError, true);
|
||
} finally {
|
||
setMatchLoading(false);
|
||
state.gameOpenBusy = false;
|
||
}
|
||
}
|
||
|
||
function applyGameData(session = null) {
|
||
if (!state.selectedGame) return;
|
||
const token = session?.token || localStorage.getItem(storage.session) || "";
|
||
window.UIBuilderRuntime?.patchData?.({
|
||
hockey: {
|
||
selected_game: state.selectedGame,
|
||
schedule: schedulePayload(),
|
||
team_schedule: state.teamSchedule || { meta: {}, items: [] },
|
||
home: state.selectedGame.home || { players: [] },
|
||
away: state.selectedGame.away || { players: [] },
|
||
referees: state.selectedGame.referees || [],
|
||
match_details: state.selectedMatchDetails || null,
|
||
standings: state.standings,
|
||
tournament_statistics: state.tournamentStatistics,
|
||
stat_labels: state.statLabels,
|
||
operator_session: session || (token ? { token } : null),
|
||
},
|
||
});
|
||
window.dispatchEvent(new CustomEvent("hockey:game-selected", {
|
||
detail: {
|
||
game: state.selectedGame,
|
||
details: state.selectedMatchDetails,
|
||
session: session || (token ? { token } : null),
|
||
language: state.language,
|
||
},
|
||
}));
|
||
}
|
||
|
||
function renderSelectedChip() {
|
||
if (!state.selectedChip) return;
|
||
state.selectedChip.classList.remove("hidden", "placeholder", "polling", "finished");
|
||
|
||
if (state.selectedGame) {
|
||
const finished = Boolean(state.selectedGame.is_finished);
|
||
state.selectedChip.classList.toggle("finished", finished);
|
||
state.selectedChip.classList.toggle("polling", !finished);
|
||
state.selectedChip.dataset.tooltip = finished
|
||
? t("updateStopped")
|
||
: t("liveUpdate");
|
||
state.selectedChip.innerHTML = `
|
||
<span>
|
||
${finished ? "✓" : "<i></i>"}
|
||
${escapeHtml(state.selectedGame.time || t("matches"))}
|
||
</span>
|
||
<strong>${escapeHtml(state.selectedGame.home.name)} — ${escapeHtml(state.selectedGame.away.name)}</strong>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
if (state.selectedTournament) {
|
||
state.selectedChip.dataset.tooltip = t("selectedTournament");
|
||
state.selectedChip.innerHTML = `
|
||
<span>${escapeHtml(state.selectedTournament.league)}</span>
|
||
<strong>${escapeHtml(state.selectedTournament.name)}</strong>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
state.selectedChip.classList.add("placeholder");
|
||
state.selectedChip.dataset.tooltip = t("chooseTournament");
|
||
state.selectedChip.innerHTML = `
|
||
<span>STAT2TV</span>
|
||
<strong>${escapeHtml(t("chooseTournament"))}</strong>
|
||
`;
|
||
}
|
||
|
||
function stopSelectedGamePolling() {
|
||
if (state.gamePollTimer) {
|
||
clearTimeout(state.gamePollTimer);
|
||
state.gamePollTimer = null;
|
||
}
|
||
state.gamePollBusy = false;
|
||
}
|
||
|
||
function selectedGameIsFinished() {
|
||
return Boolean(state.selectedGame?.is_finished);
|
||
}
|
||
|
||
function scheduleSelectedGamePoll(delay = 1000) {
|
||
stopSelectedGamePolling();
|
||
if (!state.selectedGameId || selectedGameIsFinished()) {
|
||
renderSelectedChip();
|
||
return;
|
||
}
|
||
state.gamePollTimer = setTimeout(pollSelectedGame, Math.max(250, delay));
|
||
}
|
||
|
||
function startSelectedGamePolling() {
|
||
state.gamePollErrorCount = 0;
|
||
state.gamePollStoppedGameId = "";
|
||
if (selectedGameIsFinished()) {
|
||
stopSelectedGamePolling();
|
||
state.gamePollStoppedGameId = state.selectedGameId;
|
||
renderSelectedChip();
|
||
return;
|
||
}
|
||
scheduleSelectedGamePoll(1000);
|
||
}
|
||
|
||
async function pollSelectedGame() {
|
||
if (
|
||
state.gamePollBusy
|
||
|| !state.selectedGameId
|
||
|| !state.selectedId
|
||
|| state.open
|
||
|| selectedGameIsFinished()
|
||
) {
|
||
scheduleSelectedGamePoll(1000);
|
||
return;
|
||
}
|
||
|
||
if (document.hidden) {
|
||
scheduleSelectedGamePoll(1000);
|
||
return;
|
||
}
|
||
|
||
state.gamePollBusy = true;
|
||
try {
|
||
const live = await request(
|
||
`/api/hockey/games/${encodeURIComponent(state.selectedGameId)}/live`
|
||
+ `?tournament_id=${encodeURIComponent(state.selectedId)}`
|
||
+ `&language=${encodeURIComponent(state.language)}`,
|
||
{ method: "POST", timeoutMs: 8000 }
|
||
);
|
||
const updated = live.game;
|
||
const previous = state.selectedGame || {};
|
||
if (live.changed && updated) {
|
||
state.selectedGame = updated;
|
||
state.selectedMatchDetails = live.details || state.selectedMatchDetails;
|
||
const sections = Array.isArray(live.changed_sections) && live.changed_sections.length
|
||
? `: ${live.changed_sections.join(", ")}`
|
||
: "";
|
||
notify(`${t("liveDataUpdated")}${sections}`);
|
||
} else {
|
||
state.selectedGame = {
|
||
...previous,
|
||
...(updated || {}),
|
||
home: { ...(previous.home || {}), ...(updated?.home || {}), players: previous.home?.players || [] },
|
||
away: { ...(previous.away || {}), ...(updated?.away || {}), players: previous.away?.players || [] },
|
||
referees: previous.referees || [],
|
||
};
|
||
}
|
||
state.gamePollErrorCount = 0;
|
||
applyGameData();
|
||
renderSelectedChip();
|
||
|
||
if (state.games?.items?.length) {
|
||
const index = state.games.items.findIndex(
|
||
(item) => String(item.external_id) === String(updated.external_id)
|
||
);
|
||
if (index >= 0) {
|
||
state.games.items[index] = updated;
|
||
renderGames();
|
||
}
|
||
}
|
||
|
||
if (updated.is_finished) {
|
||
stopSelectedGamePolling();
|
||
state.gamePollStoppedGameId = updated.external_id;
|
||
renderSelectedChip();
|
||
return;
|
||
}
|
||
|
||
scheduleSelectedGamePoll(Number(updated.refresh_interval_ms || 1000));
|
||
} catch (error) {
|
||
state.gamePollErrorCount += 1;
|
||
const backoff = Math.min(10000, 1000 * Math.max(2, state.gamePollErrorCount));
|
||
scheduleSelectedGamePoll(backoff);
|
||
} finally {
|
||
state.gamePollBusy = false;
|
||
}
|
||
}
|
||
|
||
document.addEventListener("visibilitychange", () => {
|
||
if (
|
||
!document.hidden
|
||
&& state.selectedGameId
|
||
&& !selectedGameIsFinished()
|
||
) {
|
||
scheduleSelectedGamePoll(150);
|
||
}
|
||
});
|
||
|
||
function diagnosticStatus(ok) {
|
||
return `<span class="hockey-diagnostic-status ${ok ? "success" : "error"}">${ok ? "● OK" : "● ERROR"}</span>`;
|
||
}
|
||
|
||
function diagnosticMessage(value) {
|
||
if (!value) return "—";
|
||
if (value.error) return String(value.error);
|
||
if (value.url) return String(value.url);
|
||
return "—";
|
||
}
|
||
|
||
async function showConnectionDiagnostics({ deep = false } = {}) {
|
||
if (!state.isAdmin) return;
|
||
const tournamentId = String(state.selectedId || "").trim();
|
||
const gameId = String(state.selectedGameId || state.lastAttemptedGameId || "").trim();
|
||
const query = new URLSearchParams({
|
||
tournament_id: tournamentId,
|
||
game_id: gameId,
|
||
language: state.language,
|
||
deep: deep ? "true" : "false",
|
||
});
|
||
|
||
let modal = document.querySelector(".hockey-connection-diagnostics-modal");
|
||
if (!modal) {
|
||
modal = document.createElement("div");
|
||
modal.className = "hockey-diagnostics-modal hockey-connection-diagnostics-modal";
|
||
modal.innerHTML = `
|
||
<div class="hockey-settings-backdrop"></div>
|
||
<div class="hockey-diagnostics-card hockey-connection-diagnostics-card">
|
||
<header>
|
||
<div><small>ADMIN / HEALTH</small><strong>${escapeHtml(t("connections"))}</strong></div>
|
||
<button type="button" data-close>×</button>
|
||
</header>
|
||
<div class="hockey-diagnostics-content" data-connection-diagnostics-content>
|
||
<div class="hockey-games-empty"><strong>…</strong></div>
|
||
</div>
|
||
<footer class="hockey-connection-diagnostics-actions">
|
||
<button type="button" class="btn" data-refresh>${escapeHtml(t("checkAgain"))}</button>
|
||
<button type="button" class="btn btn-accent" data-deep ${gameId && tournamentId ? "" : "disabled"}>${escapeHtml(t("deepMatchTest"))}</button>
|
||
</footer>
|
||
</div>
|
||
`;
|
||
document.body.appendChild(modal);
|
||
const close = () => modal.remove();
|
||
modal.querySelector("[data-close]")?.addEventListener("click", close);
|
||
modal.querySelector(".hockey-settings-backdrop")?.addEventListener("click", close);
|
||
modal.querySelector("[data-refresh]")?.addEventListener("click", () => showConnectionDiagnostics({ deep: false }));
|
||
modal.querySelector("[data-deep]")?.addEventListener("click", () => showConnectionDiagnostics({ deep: true }));
|
||
}
|
||
|
||
const content = modal.querySelector("[data-connection-diagnostics-content]");
|
||
const deepButton = modal.querySelector("[data-deep]");
|
||
if (deepButton) deepButton.disabled = !(gameId && tournamentId);
|
||
if (content) content.innerHTML = `<div class="hockey-games-empty"><strong>${escapeHtml(deep ? t("deepMatchTest") : t("checkAgain"))}…</strong></div>`;
|
||
|
||
try {
|
||
const payload = await request(`/api/hockey/admin/connection-diagnostics?${query.toString()}`, { timeoutMs: deep ? 26000 : 16000 });
|
||
const checks = payload.checks || {};
|
||
const database = checks.database || {};
|
||
const stat2tv = checks.stat2tv || {};
|
||
const agents = checks.agents || {};
|
||
const settings = checks.settings || {};
|
||
const match = checks.match || null;
|
||
const tournament = checks.tournament || null;
|
||
const selection = payload.required_for_match_selection || {};
|
||
const remote = payload.required_for_remote_sync || {};
|
||
const agentDevices = Array.isArray(agents.devices) ? agents.devices : [];
|
||
const warningItems = Array.isArray(match?.warnings) ? match.warnings : [];
|
||
|
||
content.innerHTML = `
|
||
<div class="hockey-diagnostic-summary-grid">
|
||
<div class="hockey-diagnostic-summary ${selection.ok ? "success" : "error"}">
|
||
<small>${escapeHtml(t("matchSelectionReady"))}</small>
|
||
<strong>${selection.ok ? "OK" : "ERROR"}</strong>
|
||
<span>${escapeHtml(state.language === "en" ? "Depends on PostgreSQL" : "Зависит от PostgreSQL")}</span>
|
||
</div>
|
||
<div class="hockey-diagnostic-summary ${remote.ok ? "success" : "error"}">
|
||
<small>${escapeHtml(t("remoteSyncReady"))}</small>
|
||
<strong>${remote.ok ? "OK" : "ERROR"}</strong>
|
||
<span>${escapeHtml(state.language === "en" ? "PostgreSQL + Stat2TV" : "PostgreSQL + Stat2TV")}</span>
|
||
</div>
|
||
</div>
|
||
<section class="hockey-diagnostic-section">
|
||
<header><strong>${escapeHtml(t("database"))}</strong>${diagnosticStatus(Boolean(database.ok))}</header>
|
||
<dl>
|
||
<dt>Latency</dt><dd>${escapeHtml(database.latency_ms ?? "—")} ms</dd>
|
||
<dt>Host</dt><dd>${escapeHtml(database.target?.host || "—")}</dd>
|
||
<dt>Database</dt><dd>${escapeHtml(database.target?.database || "—")}</dd>
|
||
<dt>Error</dt><dd>${escapeHtml(database.error || "—")}</dd>
|
||
</dl>
|
||
</section>
|
||
<section class="hockey-diagnostic-section">
|
||
<header><strong>${escapeHtml(t("stat2tv"))}</strong>${diagnosticStatus(Boolean(stat2tv.ok))}</header>
|
||
<dl>
|
||
<dt>Configured</dt><dd>${settings.credentials_configured ? "YES" : "NO"}</dd>
|
||
<dt>Credentials source</dt><dd>${escapeHtml(settings.credentials_source || "missing")}</dd>
|
||
<dt>Login</dt><dd>${escapeHtml(settings.username_masked || "—")}</dd>
|
||
<dt>Base URL</dt><dd>${escapeHtml(settings.base_url || "—")}</dd>
|
||
<dt>HTTP</dt><dd>${escapeHtml(stat2tv.status_code ?? "—")}</dd>
|
||
<dt>Items</dt><dd>${escapeHtml(stat2tv.items_found ?? "—")}</dd>
|
||
<dt>Latency</dt><dd>${escapeHtml(stat2tv.latency_ms ?? "—")} ms</dd>
|
||
<dt>Error</dt><dd>${escapeHtml(diagnosticMessage(stat2tv))}</dd>
|
||
</dl>
|
||
</section>
|
||
<section class="hockey-diagnostic-section">
|
||
<header><strong>${escapeHtml(t("agentVmix"))}</strong>${diagnosticStatus(Boolean(agents.ok))}</header>
|
||
<p class="hockey-diagnostic-note">${escapeHtml(t("optionalConnection"))}</p>
|
||
<dl>
|
||
<dt>Agents online</dt><dd>${escapeHtml(agents.online ?? 0)}</dd>
|
||
<dt>vMix connected</dt><dd>${escapeHtml(agents.vmix_connected ?? 0)}</dd>
|
||
<dt>Error</dt><dd>${escapeHtml(agents.error || "—")}</dd>
|
||
</dl>
|
||
${agentDevices.length ? `<div class="hockey-diagnostic-devices">${agentDevices.map((item) => `
|
||
<div><strong>${escapeHtml(item.name || item.device_id || "Agent")}</strong><span>${item.online ? "ONLINE" : "OFFLINE"} · vMix ${item.vmix_connected ? "OK" : "OFF"}${item.owner ? ` · ${escapeHtml(item.owner)}` : ""}</span></div>
|
||
`).join("")}</div>` : ""}
|
||
</section>
|
||
${tournament ? `<section class="hockey-diagnostic-section">
|
||
<header><strong>${escapeHtml(state.language === "en" ? "Tournament" : "Турнир")} ${escapeHtml(tournament.tournament_id || "")}</strong>${diagnosticStatus(Boolean(tournament.ok))}</header>
|
||
<dl><dt>Local</dt><dd>${tournament.local_found ? "FOUND" : "NOT FOUND"}</dd></dl>
|
||
</section>` : ""}
|
||
${match ? `<section class="hockey-diagnostic-section">
|
||
<header><strong>${escapeHtml(t("selectedMatchCheck"))} ${escapeHtml(match.game_id || "")}</strong>${diagnosticStatus(Boolean(match.ok))}</header>
|
||
<dl>
|
||
<dt>Local</dt><dd>${match.local_found ? "FOUND" : "NOT FOUND"}</dd>
|
||
<dt>Deep</dt><dd>${match.deep_requested ? "YES" : "NO"}</dd>
|
||
<dt>Details</dt><dd>${match.details_loaded ? "LOADED" : (match.deep_requested ? "NOT LOADED" : "—")}</dd>
|
||
<dt>Degraded</dt><dd>${match.degraded ? "YES" : "NO"}</dd>
|
||
<dt>Latency</dt><dd>${escapeHtml(match.latency_ms ?? "—")} ms</dd>
|
||
<dt>Error</dt><dd>${escapeHtml(match.error || "—")}</dd>
|
||
</dl>
|
||
${warningItems.length ? `<div class="hockey-diagnostic-warnings">${warningItems.map((item) => `<p>${escapeHtml(item)}</p>`).join("")}</div>` : ""}
|
||
</section>` : ""}
|
||
${state.lastGameOpenError ? `<section class="hockey-diagnostic-section error"><header><strong>${escapeHtml(state.language === "en" ? "Last open error" : "Последняя ошибка открытия")}</strong></header><p>${escapeHtml(state.lastGameOpenError)}</p></section>` : ""}
|
||
`;
|
||
} catch (error) {
|
||
if (content) content.innerHTML = `<div class="hockey-games-empty error"><strong>${escapeHtml(t("diagnostics"))}</strong><p>${escapeHtml(error.message)}</p></div>`;
|
||
}
|
||
}
|
||
|
||
async function showEndpointDiagnostics() {
|
||
if (!state.selectedId) return;
|
||
try {
|
||
const payload = await request(
|
||
`/api/hockey/tournaments/${encodeURIComponent(state.selectedId)}/games/diagnostics`
|
||
);
|
||
const latest = payload.items?.[0] || null;
|
||
const modal = document.createElement("div");
|
||
modal.className = "hockey-diagnostics-modal";
|
||
modal.innerHTML = `
|
||
<div class="hockey-settings-backdrop"></div>
|
||
<div class="hockey-diagnostics-card">
|
||
<header>
|
||
<div><small>STAT2TV</small><strong>${escapeHtml(t("diagnostics"))}</strong></div>
|
||
<button type="button" data-close>×</button>
|
||
</header>
|
||
<div class="hockey-diagnostics-content">
|
||
${latest ? `
|
||
<dl>
|
||
<dt>Status</dt><dd class="${latest.status === "success" ? "success" : "error"}">${escapeHtml(latest.status)}</dd>
|
||
<dt>Template</dt><dd>${escapeHtml(latest.template || "—")}</dd>
|
||
<dt>Endpoint</dt><dd>${escapeHtml(latest.endpoint || "—")}</dd>
|
||
<dt>HTTP</dt><dd>${escapeHtml(latest.status_code || "—")}</dd>
|
||
<dt>XML root</dt><dd>${escapeHtml(latest.root_tag || "—")}</dd>
|
||
<dt>Item tag</dt><dd>${escapeHtml(latest.item_tag || "—")}</dd>
|
||
<dt>Items</dt><dd>${escapeHtml(latest.item_count || 0)}</dd>
|
||
<dt>Snapshot</dt><dd>${escapeHtml(latest.snapshot_path || "—")}</dd>
|
||
<dt>Error</dt><dd>${escapeHtml(latest.error || "—")}</dd>
|
||
</dl>
|
||
<pre>${escapeHtml(JSON.stringify(latest.diagnostics || {}, null, 2))}</pre>
|
||
` : `<div class="hockey-games-empty"><strong>${escapeHtml(t("endpointNotFound"))}</strong></div>`}
|
||
</div>
|
||
</div>
|
||
`;
|
||
document.body.appendChild(modal);
|
||
const close = () => modal.remove();
|
||
modal.querySelector("[data-close]").addEventListener("click", close);
|
||
modal.querySelector(".hockey-settings-backdrop").addEventListener("click", close);
|
||
} catch (error) {
|
||
notify(error.message, true);
|
||
}
|
||
}
|
||
|
||
async function syncTournaments() {
|
||
const button = state.root.querySelector("[data-sync]");
|
||
button.disabled = true;
|
||
try {
|
||
const result = await request("/api/hockey/sync/tournaments", { method: "POST" });
|
||
notify(`${t("syncOk")}: ${result.count}`);
|
||
await loadNavigation();
|
||
} catch (error) {
|
||
notify(error.message, true);
|
||
} finally {
|
||
button.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function importSeed() {
|
||
const button = state.root.querySelector("[data-seed-import]");
|
||
button.disabled = true;
|
||
try {
|
||
const result = await request("/api/hockey/sync/tournaments/seed", { method: "POST" });
|
||
notify(`${t("syncOk")}: ${result.count}`);
|
||
await loadNavigation();
|
||
} catch (error) {
|
||
notify(error.message, true);
|
||
} finally {
|
||
button.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function openSettings() {
|
||
try {
|
||
state.settings = await request("/api/hockey/settings");
|
||
} catch (error) {
|
||
notify(error.message, true);
|
||
return;
|
||
}
|
||
|
||
const value = state.settings;
|
||
const modal = document.createElement("div");
|
||
modal.className = "hockey-settings-modal";
|
||
modal.innerHTML = `
|
||
<div class="hockey-settings-backdrop"></div>
|
||
<form class="hockey-settings-card">
|
||
<header>
|
||
<div><small>STAT2TV</small><strong>${escapeHtml(t("settings"))}</strong></div>
|
||
<button type="button" data-settings-close>×</button>
|
||
</header>
|
||
<div class="hockey-settings-body">
|
||
<label class="wide">
|
||
<span>${escapeHtml(t("apiAddress"))}</span>
|
||
<input name="base_url" value="${escapeHtml(value.base_url || "")}">
|
||
</label>
|
||
<label>
|
||
<span>${escapeHtml(t("username"))}</span>
|
||
<input name="username" placeholder="${escapeHtml(value.username_masked || "")}" autocomplete="username">
|
||
</label>
|
||
<label>
|
||
<span>${escapeHtml(t("password"))}</span>
|
||
<input name="password" type="password" autocomplete="new-password">
|
||
</label>
|
||
<label>
|
||
<span>${escapeHtml(t("authMode"))}</span>
|
||
<select name="auth_mode">
|
||
<option value="auto">Auto</option>
|
||
<option value="basic">Basic</option>
|
||
<option value="digest">Digest</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
<span>${escapeHtml(t("displayLanguage"))}</span>
|
||
<select name="ui_language">
|
||
<option value="ru">Русский</option>
|
||
<option value="en">English</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
<span>${escapeHtml(t("fallbackLanguage"))}</span>
|
||
<select name="fallback_language">
|
||
<option value="ru">Русский</option>
|
||
<option value="en">English</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
<span>${escapeHtml(t("interval"))}</span>
|
||
<input name="sync_interval_seconds" type="number" min="30" max="86400" value="${Number(value.sync_interval_seconds || 300)}">
|
||
</label>
|
||
<label>
|
||
<span>${escapeHtml(t("cacheSeconds"))}</span>
|
||
<input name="games_cache_seconds" type="number" min="0" max="86400" value="${Number(value.games_cache_seconds || 300)}">
|
||
</label>
|
||
|
||
<section class="hockey-settings-section wide">
|
||
<strong>${state.language === "en" ? "GAME CLOCK" : "ТАЙМЕР МАТЧА"}</strong>
|
||
<small>${state.language === "en" ? "Default duration for a new game and automatic reset when the period changes." : "Длительность по умолчанию для нового матча и автоматический сброс при смене периода."}</small>
|
||
</section>
|
||
<label>
|
||
<span>${state.language === "en" ? "Regulation period, min" : "Обычный период, мин"}</span>
|
||
<input name="timer_period_minutes" type="number" min="1" max="60" value="${Number(value.timer_period_minutes || 20)}">
|
||
</label>
|
||
<label>
|
||
<span>${state.language === "en" ? "Regular-season OT, min" : "Овертайм регулярки, мин"}</span>
|
||
<input name="timer_regular_overtime_minutes" type="number" min="1" max="60" value="${Number(value.timer_regular_overtime_minutes || 5)}">
|
||
</label>
|
||
<label>
|
||
<span>${state.language === "en" ? "Playoff OT, min" : "Овертайм плей-офф, мин"}</span>
|
||
<input name="timer_playoff_overtime_minutes" type="number" min="1" max="60" value="${Number(value.timer_playoff_overtime_minutes || 20)}">
|
||
</label>
|
||
<label class="check">
|
||
<input name="timer_reset_on_period_change" type="checkbox" ${value.timer_reset_on_period_change !== false ? "checked" : ""}>
|
||
<span>${state.language === "en" ? "Reset clock when period changes" : "Сбрасывать таймер при смене периода"}</span>
|
||
</label>
|
||
|
||
<section class="hockey-settings-section wide">
|
||
<strong>${state.language === "en" ? "PENALTY / STRENGTH GRAPHIC" : "УДАЛЕНИЯ И ЧИСЛЕННЫЕ СОСТАВЫ"}</strong>
|
||
<small>${state.language === "en" ? "Choose the base skater count and what the advantaged team receives in the graphic." : "Настройте базовый состав и подпись, которую получает команда в большинстве."}</small>
|
||
</section>
|
||
<label>
|
||
<span>${state.language === "en" ? "Regulation base" : "Основное время, базовый состав"}</span>
|
||
<input name="strength_regulation_skaters" type="number" min="3" max="6" value="${Number(value.strength_regulation_skaters || 5)}">
|
||
</label>
|
||
<label>
|
||
<span>${state.language === "en" ? "Regular OT base" : "Овертайм регулярки, базовый состав"}</span>
|
||
<input name="strength_regular_overtime_skaters" type="number" min="3" max="6" value="${Number(value.strength_regular_overtime_skaters || 3)}">
|
||
</label>
|
||
<label>
|
||
<span>${state.language === "en" ? "Playoff OT base" : "Овертайм плей-офф, базовый состав"}</span>
|
||
<input name="strength_playoff_overtime_skaters" type="number" min="3" max="6" value="${Number(value.strength_playoff_overtime_skaters || 5)}">
|
||
</label>
|
||
<label>
|
||
<span>${state.language === "en" ? "Minimum skaters" : "Минимум полевых игроков"}</span>
|
||
<input name="strength_min_skaters" type="number" min="2" max="5" value="${Number(value.strength_min_skaters || 3)}">
|
||
</label>
|
||
<div class="hockey-settings-note wide">
|
||
<strong>${state.language === "en" ? "Bilingual strength labels" : "Двуязычные подписи составов"}</strong>
|
||
<small>${state.language === "en" ? "Configure every 5×4, 4×4, 5×3, 4×3 and overtime label in Gear → Timers and lineups." : "Подписи для 5×4, 4×4, 5×3, 4×3 и всех вариантов овертайма настраиваются в разделе Шестерёнка → Таймеры и составы."}</small>
|
||
</div>
|
||
|
||
<label class="wide">
|
||
<span>${escapeHtml(t("endpointTemplate"))}</span>
|
||
<input name="games_endpoint_template" value="${escapeHtml(value.games_endpoint_template || "")}" placeholder="games.xml?tournament={tournament_id}">
|
||
<small>${escapeHtml(t("endpointHint"))}</small>
|
||
</label>
|
||
<label class="wide">
|
||
<span>Players XML</span>
|
||
<input name="players_endpoint_template" value="${escapeHtml(value.players_endpoint_template || "{tournament_id}/players-{tournament_id}.xml")}" placeholder="{tournament_id}/players-{tournament_id}.xml">
|
||
<small>${state.language === "en" ? "Full season roster for the selected tournament." : "Полный список игроков текущего сезона и выбранной лиги."}</small>
|
||
</label>
|
||
<label class="wide">
|
||
<span>Players countries XML</span>
|
||
<input name="player_countries_endpoint_template" value="${escapeHtml(value.player_countries_endpoint_template || "players-countries.xml")}" placeholder="players-countries.xml">
|
||
<small>${state.language === "en" ? "Global country directory and player-country links." : "Глобальный справочник стран и привязки игроков к странам."}</small>
|
||
</label>
|
||
<label class="wide">
|
||
<span>Referees XML</span>
|
||
<input name="referees_endpoint_template" value="${escapeHtml(value.referees_endpoint_template || "{tournament_id}/referees-{tournament_id}.xml")}" placeholder="{tournament_id}/referees-{tournament_id}.xml">
|
||
</label>
|
||
<label class="wide">
|
||
<span>Shots JSON</span>
|
||
<input name="shots_endpoint_template" value="${escapeHtml(value.shots_endpoint_template || "shots/shots-{game_id}.json")}" placeholder="shots/shots-{game_id}.json">
|
||
<small>${state.language === "en" ? "Optional resource. It is requested only when available." : "Необязательный ресурс. При отсутствии файла используется отрицательный кэш."}</small>
|
||
</label>
|
||
<label class="check">
|
||
<input name="verify_ssl" type="checkbox" ${value.verify_ssl ? "checked" : ""}>
|
||
<span>${escapeHtml(t("verifySsl"))}</span>
|
||
</label>
|
||
<label class="check">
|
||
<input name="games_auto_discovery" type="checkbox" ${value.games_auto_discovery !== false ? "checked" : ""}>
|
||
<span>${escapeHtml(t("endpointAuto"))}</span>
|
||
</label>
|
||
<p class="wide">${escapeHtml(t("credentialsHint"))}</p>
|
||
</div>
|
||
<footer>
|
||
<button type="button" class="btn" data-test-connection>${escapeHtml(t("test"))}</button>
|
||
<span data-settings-status></span>
|
||
<button type="submit" class="btn btn-accent">${escapeHtml(t("save"))}</button>
|
||
</footer>
|
||
</form>
|
||
`;
|
||
document.body.appendChild(modal);
|
||
state.settingsModal = modal;
|
||
|
||
const form = modal.querySelector("form");
|
||
form.elements.auth_mode.value = value.auth_mode || "auto";
|
||
form.elements.ui_language.value = value.ui_language || "ru";
|
||
form.elements.fallback_language.value = value.fallback_language || "ru";
|
||
|
||
const close = () => {
|
||
modal.remove();
|
||
state.settingsModal = null;
|
||
};
|
||
modal.querySelector("[data-settings-close]").addEventListener("click", close);
|
||
modal.querySelector(".hockey-settings-backdrop").addEventListener("click", close);
|
||
|
||
modal.querySelector("[data-test-connection]").addEventListener("click", async () => {
|
||
const status = modal.querySelector("[data-settings-status]");
|
||
status.textContent = "…";
|
||
try {
|
||
const result = await request("/api/hockey/connection/test", { method: "POST" });
|
||
status.textContent = `${t("connectionOk")} · ${result.items_found}`;
|
||
status.classList.remove("error");
|
||
} catch (error) {
|
||
status.textContent = error.message;
|
||
status.classList.add("error");
|
||
}
|
||
});
|
||
|
||
form.addEventListener("submit", async (event) => {
|
||
event.preventDefault();
|
||
const status = modal.querySelector("[data-settings-status]");
|
||
const values = new FormData(form);
|
||
try {
|
||
const savedSettings = await request("/api/hockey/settings", {
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
base_url: values.get("base_url"),
|
||
auth_mode: values.get("auth_mode"),
|
||
ui_language: values.get("ui_language"),
|
||
fallback_language: values.get("fallback_language"),
|
||
sync_interval_seconds: Number(values.get("sync_interval_seconds") || 300),
|
||
games_cache_seconds: Number(values.get("games_cache_seconds") || 300),
|
||
timer_period_minutes: Number(values.get("timer_period_minutes") || 20),
|
||
timer_regular_overtime_minutes: Number(values.get("timer_regular_overtime_minutes") || 5),
|
||
timer_playoff_overtime_minutes: Number(values.get("timer_playoff_overtime_minutes") || 20),
|
||
timer_reset_on_period_change: Boolean(values.get("timer_reset_on_period_change")),
|
||
strength_regulation_skaters: Number(values.get("strength_regulation_skaters") || 5),
|
||
strength_regular_overtime_skaters: Number(values.get("strength_regular_overtime_skaters") || 3),
|
||
strength_playoff_overtime_skaters: Number(values.get("strength_playoff_overtime_skaters") || 5),
|
||
strength_min_skaters: Number(values.get("strength_min_skaters") || 3),
|
||
games_endpoint_template: values.get("games_endpoint_template"),
|
||
players_endpoint_template: values.get("players_endpoint_template"),
|
||
player_countries_endpoint_template: values.get("player_countries_endpoint_template"),
|
||
referees_endpoint_template: values.get("referees_endpoint_template"),
|
||
shots_endpoint_template: values.get("shots_endpoint_template"),
|
||
games_auto_discovery: Boolean(values.get("games_auto_discovery")),
|
||
verify_ssl: Boolean(values.get("verify_ssl")),
|
||
}),
|
||
});
|
||
|
||
const username = String(values.get("username") || "").trim();
|
||
const password = String(values.get("password") || "");
|
||
if (username || password) {
|
||
await request("/api/hockey/credentials", {
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
username: username || null,
|
||
password: password || null,
|
||
}),
|
||
});
|
||
}
|
||
|
||
const previousLanguage = String(state.language || "ru").toLowerCase();
|
||
const nextLanguage = String(values.get("ui_language") || state.language || "ru").toLowerCase();
|
||
state.language = nextLanguage;
|
||
localStorage.setItem(storage.language, state.language);
|
||
window.dispatchEvent(new CustomEvent("hockey:settings-updated", {
|
||
detail: { settings: savedSettings || {} },
|
||
}));
|
||
|
||
// Mapping can use virtual *_RUS/*_ENG columns. Language is a global
|
||
// Hockey setting, while live Agents may belong to other operator accounts.
|
||
// Therefore refresh every active broadcast Agent, not only the admin's own.
|
||
let mappingRefresh = null;
|
||
if (previousLanguage !== nextLanguage) {
|
||
try {
|
||
mappingRefresh = await request("/api/hockey/admin/vmix-mapping/apply-all-active", { method: "POST" });
|
||
} catch (mappingError) {
|
||
status.textContent = `${t("saved")} · vMix: ${mappingError.message}`;
|
||
status.classList.add("error");
|
||
}
|
||
}
|
||
if (!status.classList.contains("error")) {
|
||
if (mappingRefresh && Number(mappingRefresh.total_devices || 0) > 0) {
|
||
status.textContent = `${t("saved")} · vMix ${Number(mappingRefresh.applied_devices || 0)}/${Number(mappingRefresh.total_devices || 0)}`;
|
||
} else {
|
||
status.textContent = t("saved");
|
||
}
|
||
status.classList.remove("error");
|
||
}
|
||
await loadNavigation();
|
||
setTimeout(close, 650);
|
||
} catch (error) {
|
||
status.textContent = error.message;
|
||
status.classList.add("error");
|
||
}
|
||
});
|
||
}
|
||
|
||
async function init() {
|
||
await ensureAccountScopedRuntimeState();
|
||
ensureShell();
|
||
renderStaticLabels();
|
||
await loadNavigation();
|
||
}
|
||
|
||
if (document.readyState === "loading") {
|
||
document.addEventListener("DOMContentLoaded", init, { once: true });
|
||
} else {
|
||
init();
|
||
}
|
||
|
||
window.HockeyTournamentMenu = {
|
||
open: () => setOpen(true),
|
||
close: () => setOpen(false),
|
||
reload: loadNavigation,
|
||
getSelectedTournamentId: () => state.selectedId,
|
||
getSelectedGameId: () => state.selectedGameId,
|
||
selectTournament: openTournament,
|
||
selectGame: openGame,
|
||
reloadGames: () => loadGames({ autoSync: false }),
|
||
};
|
||
|
||
function installGlobalTooltipPortal() {
|
||
if (document.querySelector(".hockey-global-tooltip")) return;
|
||
const tooltip = document.createElement("div");
|
||
tooltip.className = "hockey-global-tooltip";
|
||
document.body.appendChild(tooltip);
|
||
let current = null;
|
||
const hide = () => {
|
||
current = null;
|
||
tooltip.classList.remove("show");
|
||
tooltip.textContent = "";
|
||
};
|
||
const position = () => {
|
||
if (!current) return;
|
||
const rect = current.getBoundingClientRect();
|
||
const tipRect = tooltip.getBoundingClientRect();
|
||
let left = rect.left + rect.width / 2 - tipRect.width / 2;
|
||
left = Math.max(8, Math.min(window.innerWidth - tipRect.width - 8, left));
|
||
let top = rect.top - tipRect.height - 10;
|
||
if (top < 8) top = rect.bottom + 10;
|
||
tooltip.style.left = `${left}px`;
|
||
tooltip.style.top = `${Math.max(8, top)}px`;
|
||
};
|
||
const show = (target) => {
|
||
const text = String(target?.dataset?.tooltip || target?.getAttribute?.("aria-label") || "").trim();
|
||
if (!text) return;
|
||
current = target;
|
||
tooltip.textContent = text;
|
||
tooltip.classList.add("show");
|
||
requestAnimationFrame(position);
|
||
};
|
||
document.addEventListener("pointerover", (event) => {
|
||
const target = event.target.closest?.("[data-tooltip]");
|
||
if (target) show(target);
|
||
});
|
||
document.addEventListener("pointerout", (event) => {
|
||
if (!current) return;
|
||
const next = event.relatedTarget;
|
||
if (next && current.contains(next)) return;
|
||
hide();
|
||
});
|
||
document.addEventListener("focusin", (event) => {
|
||
const target = event.target.closest?.("[data-tooltip]");
|
||
if (target) show(target);
|
||
});
|
||
document.addEventListener("focusout", hide);
|
||
window.addEventListener("scroll", hide, true);
|
||
window.addEventListener("resize", hide);
|
||
}
|
||
installGlobalTooltipPortal();
|
||
})();
|