This commit is contained in:
2026-08-19 15:08:39 +03:00
parent 478557f3ad
commit a03c54dbe8
5148 changed files with 31553313 additions and 0 deletions

View File

@@ -0,0 +1,391 @@
(() => {
"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("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
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);
})();