392 lines
17 KiB
JavaScript
392 lines
17 KiB
JavaScript
(() => {
|
||
"use strict";
|
||
|
||
const boot = window.UI_BUILDER_BOOT || {};
|
||
if (boot.mode !== "runtime") return;
|
||
|
||
const state = {
|
||
root: null,
|
||
signature: "",
|
||
timerId: "hockey_game_timer",
|
||
dashboardId: "hockey_penalty_dashboard",
|
||
interval: null,
|
||
resizeObserver: null,
|
||
control: null,
|
||
controlGameId: "",
|
||
controlLoading: false,
|
||
controlFetchedAt: 0,
|
||
periodEditing: false,
|
||
};
|
||
|
||
const escapeHtml = (value) => String(value ?? "")
|
||
.replaceAll("&", "&")
|
||
.replaceAll("<", "<")
|
||
.replaceAll(">", ">")
|
||
.replaceAll('"', """)
|
||
.replaceAll("'", "'");
|
||
|
||
function formatDate(value, language) {
|
||
const raw = String(value || "").trim();
|
||
if (!raw) return "Дата не указана";
|
||
const parsed = new Date(`${raw}T12:00:00`);
|
||
if (Number.isNaN(parsed.getTime())) return raw;
|
||
return parsed.toLocaleDateString(language === "en" ? "en-GB" : "ru-RU", {
|
||
day: "2-digit",
|
||
month: "short",
|
||
year: "numeric",
|
||
});
|
||
}
|
||
|
||
function formatPenaltyTime(milliseconds) {
|
||
const totalSeconds = Math.max(0, Math.ceil(Number(milliseconds || 0) / 1000));
|
||
const minutes = Math.floor(totalSeconds / 60);
|
||
const seconds = totalSeconds % 60;
|
||
return `${minutes}:${String(seconds).padStart(2, "0")}`;
|
||
}
|
||
|
||
function discoverActionIds() {
|
||
const components = window.UIBuilderRuntime?.getConfig?.()?.components || [];
|
||
const timer = components.find((item) => item.action_id === "hockey_game_timer")
|
||
|| components.find((item) => item.type === "timer");
|
||
const dashboard = components.find((item) => item.type === "hockey_penalty_dashboard");
|
||
state.timerId = timer?.action_id || "hockey_game_timer";
|
||
state.dashboardId = dashboard?.action_id || "hockey_penalty_dashboard";
|
||
}
|
||
|
||
function snapshot() {
|
||
const data = window.UIBuilderRuntime?.getData?.() || {};
|
||
const game = data.hockey?.selected_game || null;
|
||
const tournament = data.hockey?.selected_tournament || null;
|
||
const language = data.hockey?.language?.display
|
||
|| localStorage.getItem("hockey.language")
|
||
|| "ru";
|
||
const timer = window.UIBuilderRuntime?.getTimer?.(state.timerId) || null;
|
||
const board = window.UIBuilderRuntime?.getHockeyPenaltyBoard?.(state.dashboardId) || {};
|
||
const penalties = (Array.isArray(board.penalties) ? board.penalties : [])
|
||
.filter((event) => (
|
||
event
|
||
&& !event.finished
|
||
&& (event.player || (event.teamPenalty && ["home", "away"].includes(event.side)))
|
||
&& event.infraction
|
||
&& event.preset
|
||
))
|
||
.sort((left, right) => {
|
||
const sideOrder = { home: 0, away: 1 };
|
||
return (sideOrder[left.player?.side || left.side] ?? 2)
|
||
- (sideOrder[right.player?.side || right.side] ?? 2)
|
||
|| Number(left.createdAt || 0) - Number(right.createdAt || 0);
|
||
});
|
||
return { data, game, tournament, language, timer, penalties, control: state.control };
|
||
}
|
||
|
||
async function controlRequest(path, options = {}) {
|
||
const response = await fetch(`/api/hockey${path}`, {
|
||
cache: "no-store",
|
||
credentials: "same-origin",
|
||
...options,
|
||
headers: {
|
||
...(options.body ? { "Content-Type": "application/json" } : {}),
|
||
...(options.headers || {}),
|
||
},
|
||
});
|
||
let payload = {};
|
||
try { payload = await response.json(); } catch (_) {}
|
||
if (!response.ok) {
|
||
const detail = typeof payload.detail === "string" ? payload.detail : `HTTP ${response.status}`;
|
||
throw new Error(detail);
|
||
}
|
||
return payload;
|
||
}
|
||
|
||
async function loadControl(force = false) {
|
||
const data = window.UIBuilderRuntime?.getData?.() || {};
|
||
const gameId = String(data.hockey?.selected_game?.external_id || data.hockey?.selected_game?.id || "").trim();
|
||
const language = data.hockey?.language?.display === "en" ? "en" : "ru";
|
||
if (!gameId) {
|
||
state.control = null;
|
||
state.controlGameId = "";
|
||
return;
|
||
}
|
||
if (state.controlGameId !== gameId) {
|
||
state.control = null;
|
||
state.controlGameId = gameId;
|
||
force = true;
|
||
}
|
||
if (state.controlLoading) return;
|
||
if (!force && Date.now() - state.controlFetchedAt < 1800) return;
|
||
state.controlLoading = true;
|
||
try {
|
||
const previousPeriod = String(state.control?.current_period || "");
|
||
state.control = await controlRequest(`/games/${encodeURIComponent(gameId)}/control?language=${language}`);
|
||
state.controlFetchedAt = Date.now();
|
||
const nextPeriod = String(state.control?.current_period || "");
|
||
if (previousPeriod && nextPeriod && previousPeriod !== nextPeriod) {
|
||
window.UIBuilderRuntime?.patchData?.({ hockey: { game_control: state.control } });
|
||
window.dispatchEvent(new CustomEvent("hockey:game-control-updated", {
|
||
detail: { game_id: gameId, control: state.control, apply_timers: true },
|
||
}));
|
||
}
|
||
render(true);
|
||
} catch (error) {
|
||
console.error("Could not load hockey period state", error);
|
||
} finally {
|
||
state.controlLoading = false;
|
||
}
|
||
}
|
||
|
||
async function updatePeriod(gameId, value, language) {
|
||
try {
|
||
state.control = await controlRequest(`/games/${encodeURIComponent(gameId)}/control/period`, {
|
||
method: "PUT",
|
||
body: JSON.stringify({ current_period: value, language }),
|
||
});
|
||
state.controlFetchedAt = Date.now();
|
||
window.UIBuilderRuntime?.patchData?.({ hockey: { game_control: state.control } });
|
||
window.dispatchEvent(new CustomEvent("hockey:game-control-updated", {
|
||
detail: { game_id: gameId, control: state.control, apply_timers: true },
|
||
}));
|
||
render(true);
|
||
} catch (error) {
|
||
console.error("Could not update hockey period", error);
|
||
window.alert(error.message || "Не удалось изменить период");
|
||
await loadControl(true);
|
||
}
|
||
}
|
||
|
||
function timerStatus(timer, language) {
|
||
if (!timer) return language === "en" ? "not configured" : "не настроен";
|
||
if (timer.finished) return language === "en" ? "finished" : "завершён";
|
||
if (timer.running) return language === "en" ? "running" : "идёт";
|
||
if (timer.paused) return language === "en" ? "paused" : "пауза";
|
||
return language === "en" ? "stopped" : "остановлен";
|
||
}
|
||
|
||
function penaltyMarkup(event, language, teamNames = {}) {
|
||
const side = event.player?.side || event.side || "neutral";
|
||
const teamPenalty = Boolean(event.teamPenalty);
|
||
const number = String(event.player?.number || "").replace(/^#/, "");
|
||
const playerName = teamPenalty
|
||
? (teamNames[side] || (language === "en" ? "Team penalty" : "Командное удаление"))
|
||
: (event.player?.name || (language === "en" ? "Player" : "Игрок"));
|
||
const infraction = event.infraction?.label
|
||
|| event.infraction?.name
|
||
|| event.infraction?.id
|
||
|| "";
|
||
const accessibleLabel = [playerName, infraction, formatPenaltyTime(event.remainingMs)]
|
||
.filter(Boolean)
|
||
.join(" · ");
|
||
return `
|
||
<article class="hockey-status-penalty side-${escapeHtml(side)} ${teamPenalty ? "is-team-penalty" : ""} ${event.running ? "is-running" : "is-paused"}" aria-label="${escapeHtml(accessibleLabel)}" title="${escapeHtml(accessibleLabel)}">
|
||
<span class="hockey-status-penalty-side" aria-hidden="true"></span>
|
||
<strong>${teamPenalty ? (language === "en" ? "TEAM" : "КОМ") : number ? `#${escapeHtml(number)}` : "—"}</strong>
|
||
<time>${escapeHtml(formatPenaltyTime(event.remainingMs))}</time>
|
||
</article>
|
||
`;
|
||
}
|
||
|
||
function render(force = false) {
|
||
if (!state.root || !window.UIBuilderRuntime) return;
|
||
// The header refreshes frequently because the timer changes. Replacing the
|
||
// <select> while its native popup is open closes the popup in Chromium.
|
||
// Freeze this header only for the duration of period selection.
|
||
const periodSelect = state.root.querySelector("[data-hockey-current-period]");
|
||
if (state.periodEditing || periodSelect === document.activeElement) return;
|
||
const value = snapshot();
|
||
const game = value.game;
|
||
const language = value.language === "en" ? "en" : "ru";
|
||
const home = game?.home || {};
|
||
const away = game?.away || {};
|
||
const homeName = home.name || (language === "en" ? "Home team" : "Хозяева");
|
||
const awayName = away.name || (language === "en" ? "Away team" : "Гости");
|
||
const scoreHome = Number.isFinite(Number(home.score)) ? Number(home.score) : 0;
|
||
const scoreAway = Number.isFinite(Number(away.score)) ? Number(away.score) : 0;
|
||
const clock = value.timer?.formatted || "—:—";
|
||
const dateText = formatDate(game?.date, language);
|
||
const startTime = String(game?.time || "").trim();
|
||
const league = value.tournament?.league || value.tournament?.name || "";
|
||
const finishLabel = String(game?.score?.finish_label || "").trim()
|
||
|| (String(game?.score?.finish_type || "").toUpperCase() === "SO" ? (language === "en" ? "SO" : "Б")
|
||
: String(game?.score?.finish_type || "").toUpperCase() === "OT" ? (language === "en" ? "OT" : "ОТ") : "");
|
||
const stageKey = String(value.tournament?.stage_key || "").toLowerCase();
|
||
const fallbackPeriodOptions = [
|
||
{ value: "1", label: language === "en" ? "1st period" : "1 период" },
|
||
{ value: "2", label: language === "en" ? "2nd period" : "2 период" },
|
||
{ value: "3", label: language === "en" ? "3rd period" : "3 период" },
|
||
...(stageKey === "playoff"
|
||
? [
|
||
{ value: "ot1", label: language === "en" ? "Overtime 1" : "1 овертайм" },
|
||
{ value: "ot2", label: language === "en" ? "Overtime 2" : "2 овертайм" },
|
||
]
|
||
: [
|
||
{ value: "ot", label: language === "en" ? "Overtime" : "Овертайм" },
|
||
...(stageKey === "regular"
|
||
? [{ value: "so", label: language === "en" ? "Shootout" : "Буллиты" }]
|
||
: []),
|
||
]),
|
||
{ value: "finished", label: language === "en" ? "Finished" : "Матч завершён" },
|
||
];
|
||
const periodOptions = Array.isArray(value.control?.period_options)
|
||
? value.control.period_options
|
||
: fallbackPeriodOptions;
|
||
const currentPeriod = String(value.control?.current_period || "1");
|
||
const strength = value.control?.strength || {};
|
||
const strengthLabel = String(strength.strength_label || "");
|
||
const strengthAdvantage = String(strength.advantage_side || "");
|
||
const strengthHomeLabel = String(strength.home_label || "");
|
||
const strengthAwayLabel = String(strength.away_label || "");
|
||
const strengthStateLabel = String(strength.state_label || "");
|
||
const signature = JSON.stringify({
|
||
game: game?.external_id || "",
|
||
homeName,
|
||
awayName,
|
||
scoreHome,
|
||
scoreAway,
|
||
dateText,
|
||
startTime,
|
||
league,
|
||
finishLabel,
|
||
currentPeriod,
|
||
strengthLabel,
|
||
strengthAdvantage,
|
||
strengthHomeLabel,
|
||
strengthAwayLabel,
|
||
strengthStateLabel,
|
||
clock,
|
||
timerRunning: Boolean(value.timer?.running),
|
||
timerPaused: Boolean(value.timer?.paused),
|
||
penalties: value.penalties.map((event) => [
|
||
event.id,
|
||
event.player?.name,
|
||
event.player?.number,
|
||
event.player?.side || event.side,
|
||
event.teamPenalty,
|
||
event.infraction?.label,
|
||
event.running,
|
||
formatPenaltyTime(event.remainingMs),
|
||
]),
|
||
language,
|
||
});
|
||
if (!force && signature === state.signature) return;
|
||
state.signature = signature;
|
||
|
||
const penalties = value.penalties.length
|
||
? value.penalties.map((event) => penaltyMarkup(event, language, { home: homeName, away: awayName })).join("")
|
||
: `<div class="hockey-status-no-penalties">${language === "en" ? "No active penalties" : "Нет активных удалений"}</div>`;
|
||
const advantageTeam = strengthAdvantage === "home" ? homeName : strengthAdvantage === "away" ? awayName : "";
|
||
const advantageText = strengthAdvantage === "home" ? strengthHomeLabel : strengthAdvantage === "away" ? strengthAwayLabel : strengthStateLabel;
|
||
const strengthMarkup = game && strengthLabel ? `
|
||
<article class="hockey-status-strength ${strengthAdvantage ? `side-${escapeHtml(strengthAdvantage)}` : "is-even"}">
|
||
<span>${language === "en" ? "STRENGTH" : "СОСТАВЫ"}</span>
|
||
<strong>${escapeHtml(strengthLabel)}</strong>
|
||
<small>${escapeHtml(advantageText ? `${advantageText}${advantageTeam ? ` · ${advantageTeam}` : ""}` : (language === "en" ? "EVEN" : "РАВНЫЕ"))}</small>
|
||
</article>` : "";
|
||
|
||
state.root.classList.toggle("has-game", Boolean(game));
|
||
state.root.innerHTML = `
|
||
<div class="hockey-status-match">
|
||
<div class="hockey-status-team is-home">
|
||
<span>${language === "en" ? "HOME" : "ХОЗЯЕВА"}</span>
|
||
<strong>${escapeHtml(homeName)}</strong>
|
||
</div>
|
||
<div class="hockey-status-score" title="${game ? "" : (language === "en" ? "Select a game" : "Выберите матч")}">
|
||
<strong>${game ? `${scoreHome} : ${scoreAway}` : "— : —"}</strong>
|
||
${finishLabel ? `<small>${escapeHtml(finishLabel)}</small>` : ""}
|
||
</div>
|
||
<div class="hockey-status-team is-away">
|
||
<span>${language === "en" ? "AWAY" : "ГОСТИ"}</span>
|
||
<strong>${escapeHtml(awayName)}</strong>
|
||
</div>
|
||
</div>
|
||
<div class="hockey-status-meta">
|
||
${league ? `<span>${escapeHtml(league)}</span>` : ""}
|
||
<strong>${escapeHtml(dateText)}</strong>
|
||
<time>${escapeHtml(startTime || (language === "en" ? "time not set" : "время не указано"))}</time>
|
||
</div>
|
||
<label class="hockey-status-period">
|
||
<span>${language === "en" ? "CURRENT PERIOD" : "ТЕКУЩИЙ ПЕРИОД"}</span>
|
||
<select data-hockey-current-period ${game ? "" : "disabled"}>${periodOptions.map((item) => `<option value="${escapeHtml(item.value)}" ${String(item.value) === currentPeriod ? "selected" : ""}>${escapeHtml(item.label)}</option>`).join("")}</select>
|
||
</label>
|
||
<div class="hockey-status-clock ${value.timer?.running ? "is-running" : "is-paused"}">
|
||
<span>${language === "en" ? "MAIN TIMER" : "ОСНОВНОЙ ТАЙМЕР"}</span>
|
||
<strong>${escapeHtml(clock)}</strong>
|
||
<small>${escapeHtml(timerStatus(value.timer, language))}</small>
|
||
</div>
|
||
<div class="hockey-status-penalties" aria-label="${language === "en" ? "Active penalties" : "Активные удаления"}">
|
||
${strengthMarkup}
|
||
${penalties}
|
||
</div>
|
||
`;
|
||
const currentPeriodSelect = state.root.querySelector("[data-hockey-current-period]");
|
||
currentPeriodSelect?.addEventListener("pointerdown", () => { state.periodEditing = true; });
|
||
currentPeriodSelect?.addEventListener("focus", () => { state.periodEditing = true; });
|
||
currentPeriodSelect?.addEventListener("change", (event) => {
|
||
if (!game?.external_id) return;
|
||
updatePeriod(String(game.external_id), event.target.value, language);
|
||
});
|
||
currentPeriodSelect?.addEventListener("blur", () => {
|
||
state.periodEditing = false;
|
||
window.setTimeout(() => render(true), 0);
|
||
});
|
||
}
|
||
|
||
function mount() {
|
||
if (state.root) return;
|
||
const topbar = document.querySelector(".runtime-topbar");
|
||
if (!topbar) return;
|
||
const root = document.createElement("section");
|
||
root.id = "hockeyMatchStatusHeader";
|
||
root.className = "hockey-match-status";
|
||
root.setAttribute("aria-label", "Краткое состояние матча");
|
||
root.tabIndex = 0;
|
||
root.title = "Нажмите, чтобы выбрать турнир или матч";
|
||
root.addEventListener("click", (event) => {
|
||
if (!event.target.closest(".hockey-status-match")) return;
|
||
document.querySelector(".runtime-actions .hockey-selected-chip")?.click();
|
||
});
|
||
root.addEventListener("keydown", (event) => {
|
||
if (event.key !== "Enter" && event.key !== " ") return;
|
||
event.preventDefault();
|
||
document.querySelector(".runtime-actions .hockey-selected-chip")?.click();
|
||
});
|
||
topbar.appendChild(root);
|
||
state.root = root;
|
||
discoverActionIds();
|
||
render(true);
|
||
if (typeof ResizeObserver === "function") {
|
||
state.resizeObserver = new ResizeObserver(() => {
|
||
window.UIBuilderRuntime?.refreshLayout?.();
|
||
});
|
||
state.resizeObserver.observe(topbar);
|
||
}
|
||
requestAnimationFrame(() => window.UIBuilderRuntime?.refreshLayout?.());
|
||
state.interval = window.setInterval(() => {
|
||
if (!document.hidden) {
|
||
render();
|
||
loadControl(false);
|
||
}
|
||
}, 250);
|
||
}
|
||
|
||
window.addEventListener("hockey:game-selected", () => { loadControl(true); render(true); });
|
||
window.addEventListener("hockey:settings-updated", () => { loadControl(true); render(true); });
|
||
window.addEventListener("hockey:game-control-updated", (event) => {
|
||
const payload = event.detail?.control;
|
||
const gameId = String(event.detail?.game_id || payload?.game_id || "");
|
||
if (!payload || (state.controlGameId && gameId && state.controlGameId !== gameId)) return;
|
||
state.control = payload;
|
||
state.controlFetchedAt = Date.now();
|
||
render(true);
|
||
});
|
||
window.addEventListener("ui-builder:data-patched", () => render(true));
|
||
document.addEventListener("visibilitychange", () => {
|
||
if (!document.hidden) render(true);
|
||
});
|
||
|
||
if (document.readyState === "loading") {
|
||
document.addEventListener("DOMContentLoaded", mount, { once: true });
|
||
} else {
|
||
mount();
|
||
}
|
||
loadControl(true);
|
||
})();
|