Files
hockey_new/hockey_data/static/match-status-header.js
2026-08-19 15:08:39 +03:00

392 lines
17 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 || {};
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);
})();