bulid 63
This commit is contained in:
391
hockey_data/static/match-status-header.js
Normal file
391
hockey_data/static/match-status-header.js
Normal 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("<", "<")
|
||||
.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);
|
||||
})();
|
||||
Reference in New Issue
Block a user