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

2139 lines
86 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

(() => {
"use strict";
const boot = window.UI_BUILDER_BOOT || {};
const storage = {
language: "hockey.language",
tournament: "hockey.selectedTournamentId",
game: "hockey.selectedGameId",
gameDate: "hockey.gameDate",
session: "hockey.operatorSessionToken",
vmixDevice: "hockey.vmix.selected_device",
account: "hockey.accountId",
};
const state = {
open: false,
loading: false,
language: localStorage.getItem(storage.language) || "ru",
view: "tournaments",
mode: "today",
search: "",
league: "",
season: "",
stage: "",
selectedId: localStorage.getItem(storage.tournament) || "",
selectedGameId: localStorage.getItem(storage.game) || "",
selectedTournament: null,
selectedGame: null,
selectedMatchDetails: null,
standings: null,
tournamentStatistics: null,
statLabels: null,
gameDate: localStorage.getItem(storage.gameDate) || localDateIso(),
navigation: null,
games: null,
teamSchedule: null,
teamScheduleTournamentId: "",
settings: null,
root: null,
drawer: null,
backdrop: null,
selectedChip: null,
settingsModal: null,
gamesAutoSyncAttempted: new Set(),
scheduleSyncBusy: false,
gameOpenBusy: false,
gamePollTimer: null,
gamePollBusy: false,
gamePollErrorCount: 0,
gamePollStoppedGameId: "",
};
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: "Диагностика",
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",
startTime: "Start",
arena: "Arena",
gameNumber: "Game",
score: "Score",
unknownTeams: "Teams were not recognized",
syncFailed: "Failed to load games",
currentSession: "Active session",
datePrevious: "Previous day",
dateNext: "Next day",
dateToday: "Go to today",
cached: "Local database",
fromApi: "Stat2TV",
endpointFound: "Games endpoint found",
endpointNotFound: "Games endpoint not found yet",
statusScheduled: "Scheduled",
statusLive: "Live",
statusFinished: "Finished",
series: "Series",
manualMatchTitle: "Load by game ID",
manualMatchHint: "Enter a game ID — the league and tournament will be detected automatically from the available JSON.",
manualMatchPlaceholder: "For example, 901687",
manualMatchButton: "Load",
manualMatchInvalid: "Enter a valid game ID",
liveDataUpdated: "Match data updated",
finishOvertime: "OT",
finishShootout: "SO",
},
};
function t(key) {
return text[state.language]?.[key] || text.ru[key] || key;
}
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function localDateIso() {
const now = new Date();
const offset = now.getTimezoneOffset();
return new Date(now.getTime() - offset * 60000).toISOString().slice(0, 10);
}
function shiftDate(value, days) {
const date = new Date(`${value}T12:00:00`);
date.setDate(date.getDate() + days);
const offset = date.getTimezoneOffset();
return new Date(date.getTime() - offset * 60000).toISOString().slice(0, 10);
}
function formatDate(value) {
if (!value) return "—";
try {
return new Intl.DateTimeFormat(
state.language === "en" ? "en-GB" : "ru-RU",
{ day: "2-digit", month: "long", year: "numeric" }
).format(new Date(`${value}T12:00:00`));
} catch (_) {
return value;
}
}
function currentAgentDeviceId() {
return String(window.HockeyAgentRuntime?.currentDeviceId?.() || localStorage.getItem(storage.vmixDevice) || "").trim();
}
async function ensureAccountScopedRuntimeState() {
let user = null;
try {
user = await request("/api/hockey/me");
} catch (_) {
return;
}
const currentAccountId = String(user?.id || "").trim();
if (!currentAccountId) return;
const previousAccountId = String(localStorage.getItem(storage.account) || "").trim();
if (previousAccountId && previousAccountId !== currentAccountId) {
// Session tokens and Agent selection belong to one WFL account only.
// Keeping them across logout/login can bind a new operator to the
// previous account's match or vMix device.
localStorage.removeItem(storage.session);
localStorage.removeItem(storage.game);
localStorage.removeItem(storage.vmixDevice);
state.selectedGameId = "";
state.selectedGame = null;
state.selectedMatchDetails = null;
stopSelectedGamePolling();
try { window.HockeyAgentRuntime?.selectDevice?.(""); } catch (_) {}
}
localStorage.setItem(storage.account, currentAccountId);
}
async function request(path, options = {}) {
const { timeoutMs = 15000, ...fetchOptions } = options;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(path, {
cache: "no-store",
credentials: "same-origin",
...fetchOptions,
signal: controller.signal,
});
let payload = {};
try {
payload = await response.json();
} catch (_) {}
if (!response.ok) {
if (response.status === 401) {
window.location.assign("/");
}
const detail = payload.detail;
throw new Error(
typeof detail === "string"
? detail
: detail?.message || `HTTP ${response.status}`
);
}
return payload;
} catch (error) {
if (error?.name === "AbortError") {
throw new Error("Превышено время ожидания ответа Stat2TV");
}
throw error;
} finally {
clearTimeout(timeout);
}
}
function notify(message, error = false) {
if (!error && window.HOCKEY_RUNTIME_TOASTS_ENABLED === false) return;
let toast = document.querySelector(".hockey-module-toast");
if (!toast) {
toast = document.createElement("div");
toast.className = "hockey-module-toast";
document.body.appendChild(toast);
}
toast.textContent = message;
toast.classList.toggle("error", error);
toast.classList.add("show");
clearTimeout(toast._hideTimer);
toast._hideTimer = setTimeout(() => toast.classList.remove("show"), 3200);
}
function matchLabelById(id) {
const game = (state.games?.items || []).find(
(item) => String(item?.external_id || item?.id || "") === String(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>
${boot.mode === "editor" ? `
<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>
<div class="hockey-connection-bar" data-connection-bar>
<span></span>
<strong>${escapeHtml(t("notConfigured"))}</strong>
</div>
<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>
${boot.mode === "editor" ? `
<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.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", showDiagnostics);
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() {
const meta = state.navigation?.meta || {};
const bar = state.root.querySelector("[data-connection-bar]");
const configured = Boolean(meta.credentials_configured);
bar.classList.toggle("connected", configured);
bar.querySelector("strong").textContent = configured ? t("configured") : t("notConfigured");
const last = meta.last_sync;
bar.dataset.tooltip = last?.source === "stat2tv" && last?.status === "success"
? `${t("sourceApi")} · ${last.item_count || 0}`
: t("sourceSeed");
}
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(boot.mode === "editor" ? t("diagnostics") : "")}</small>
</div>
`;
}
}
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 = `
<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")
: t("notConfigured")
)}</small>
</div>
`;
return;
}
list.innerHTML = items.map(gameCard).join("");
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 openGame(id, { manual = false } = {}) {
if (state.gameOpenBusy) return;
state.gameOpenBusy = true;
const loadingGame = matchLabelById(id) || `${t("gameNumber")} ID ${id}`;
setMatchLoading(true, { game: loadingGame, stage: t("loadingMatchDetails") });
try {
try {
const endpoint = manual
? `/api/hockey/games/manual/${encodeURIComponent(id)}?language=${encodeURIComponent(state.language)}`
: `/api/hockey/games/${encodeURIComponent(id)}/details/sync?tournament_id=${encodeURIComponent(state.selectedId)}&language=${encodeURIComponent(state.language)}`;
const loaded = await request(
endpoint,
{ method: "POST", timeoutMs: manual ? 60000 : 30000 }
);
state.selectedGame = loaded.game;
state.selectedMatchDetails = loaded.details || null;
} catch (_) {
// The cached schedule remains usable if Stat2TV is temporarily offline.
try {
const cached = await request(
`/api/hockey/games/${encodeURIComponent(id)}/details?language=${state.language}`
);
state.selectedGame = cached.game;
state.selectedMatchDetails = cached.details || null;
} catch (_) {
state.selectedGame = await request(
`/api/hockey/games/${encodeURIComponent(id)}?language=${state.language}`
);
state.selectedMatchDetails = null;
}
}
if (manual && state.selectedGame?.tournament_external_id && String(state.selectedGame.tournament_external_id) !== String(state.selectedId)) {
state.selectedId = String(state.selectedGame.tournament_external_id);
localStorage.setItem(storage.tournament, state.selectedId);
try {
state.selectedTournament = await request(`/api/hockey/tournaments/${encodeURIComponent(state.selectedId)}?language=${encodeURIComponent(state.language)}`);
state.standings = null;
state.tournamentStatistics = null;
state.games = null;
applyTournamentData();
} catch (_) {}
}
updateMatchLoading(t("loadingMatchSession"));
const previousToken = localStorage.getItem(storage.session);
if (previousToken) {
try {
const oldSession = await request(`/api/hockey/sessions/${encodeURIComponent(previousToken)}`);
if (oldSession.game_external_id !== 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: 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) {
// A device stored in localStorage may belong to the previous account.
// Retry the operator session without Agent instead of blocking the match.
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.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();
const scheduleDate = String(state.games?.meta?.date || state.games?.selected_date || "").trim();
const scheduleTournament = String(state.games?.meta?.tournament_external_id || "").trim();
if (
selectedGameDate
&& (selectedGameDate !== scheduleDate || scheduleTournament !== String(state.selectedId))
) {
state.gameDate = selectedGameDate;
localStorage.setItem(storage.gameDate, state.gameDate);
state.games = null;
applyGamesData();
await loadGames({ autoSync: false });
}
applyGameData(session);
updateMatchLoading(t("loadingMatchStandings"));
await loadTournamentStandings({ sync: false });
renderGames();
renderSelectedChip();
startSelectedGamePolling();
updateMatchLoading(t("loadingMatchFinishing"));
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" : "судей"}`
: "";
notify(
`${t("gameOpened")}: ${state.selectedGame.home.name}${state.selectedGame.away.name}${loadedText}`
);
if (manual) state.root?.querySelectorAll("[data-manual-game-id]").forEach((manualInput) => { manualInput.value = ""; });
setOpen(false);
} catch (error) {
notify(error.message, 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);
}
});
async function showDiagnostics() {
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() {
ensureShell();
renderStaticLabels();
await ensureAccountScopedRuntimeState();
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();
})();