Files
hockey_new/hockey_data/static/match-status-header.js

478 lines
22 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 runtimeStorage = window.HockeyMatchAccess?.storage || localStorage;
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,
controlContextKey: "",
controlRefreshQueued: false,
controlReadRevision: 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
|| runtimeStorage.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;
}
function controlContext() {
const hockey = window.UIBuilderRuntime?.getData?.()?.hockey || {};
const gameId = String(hockey.selected_game?.external_id || hockey.selected_game?.id || "").trim();
const language = hockey.language?.display === "en" ? "en" : "ru";
const session = hockey.operator_session || hockey.session || {};
const token = String(session.token || session.channel_key || "");
return { gameId, language, key: JSON.stringify([gameId, language, token]) };
}
async function loadControl(force = false) {
const { gameId, language, key } = controlContext();
if (!gameId) {
state.control = null;
state.controlGameId = "";
state.controlContextKey = key;
return;
}
if (state.controlContextKey !== key) {
state.control = null;
state.controlGameId = gameId;
state.controlContextKey = key;
state.controlFetchedAt = 0;
force = true;
}
if (state.controlLoading) {
if (force) state.controlRefreshQueued = true;
return;
}
if (!force && Date.now() - state.controlFetchedAt < 1800) return;
state.controlLoading = true;
const readRevision = state.controlReadRevision;
try {
const previousPeriod = String(state.control?.current_period || "");
const payload = await controlRequest(`/games/${encodeURIComponent(gameId)}/control?language=${language}`);
if (key !== state.controlContextKey || key !== controlContext().key) {
state.controlRefreshQueued = true;
return;
}
// A timer/period mutation can publish a newer snapshot while this read
// is pending. Keep that snapshot until the next ordinary refresh.
if (readRevision !== state.controlReadRevision) return;
state.control = payload;
state.controlFetchedAt = Date.now();
const nextPeriod = String(state.control?.current_period || "");
if (!window.HockeyMatchAccess?.canControl?.(gameId)) {
window.dispatchEvent(new CustomEvent("hockey:control-snapshot", {
detail: { game_id: gameId, control: state.control },
}));
} else 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;
if (state.controlRefreshQueued) {
state.controlRefreshQueued = false;
void loadControl(true);
}
}
}
async function updatePeriod(gameId, value, language) {
if (!window.HockeyMatchAccess?.canControl?.(gameId)) return;
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;
const periodSelect = state.root.querySelector("[data-hockey-current-period]");
const periodShell = periodSelect?.closest(".hockey-status-period");
const periodBusy = Boolean(state.periodEditing || (periodShell && (
periodShell.contains(document.activeElement) || periodShell.querySelector(".control-popover-open")
)));
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 canEditClock = Boolean(value.timer && game
&& window.HockeyMatchAccess?.canControl?.(String(game.external_id || game.id || "")));
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 || "",
canControl: window.HockeyMatchAccess?.canControl?.(String(game?.external_id || game?.id || "")) === true,
homeName,
awayName,
scoreHome,
scoreAway,
dateText,
startTime,
league,
finishLabel,
currentPeriod,
periodOptions,
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));
const markup = `
<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 && window.HockeyMatchAccess?.canControl?.(String(game.external_id || game.id || "")) ? "" : "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"} ${canEditClock ? "is-editable" : ""}" title="${canEditClock ? (language === "en" ? "Double-click to edit the main timer" : "Двойной клик — изменить основное время") : ""}">
<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 periodSignature = JSON.stringify([game?.external_id || "", language, currentPeriod, periodOptions,
Boolean(game && window.HockeyMatchAccess?.canControl?.(String(game.external_id || game.id || "")))]);
const fragment = document.createElement("template");
fragment.innerHTML = markup;
for (const fresh of Array.from(fragment.content.children)) {
const current = state.root.querySelector(`.${fresh.classList[0]}`);
if (fresh.classList.contains("hockey-status-period")) {
const sameGame = state.root.dataset.periodGameId === String(game?.external_id || "");
if (current && sameGame && (periodBusy || state.root.dataset.periodSignature === periodSignature)) {
if (state.root.dataset.periodSignature !== periodSignature) state.signature = "";
continue;
}
state.periodEditing = false;
state.root.dataset.periodSignature = periodSignature;
state.root.dataset.periodGameId = String(game?.external_id || "");
} else if (current && fresh.classList.contains("hockey-status-clock")) {
// Keep the click target alive between the two clicks, including when
// a running clock ticks or live match data refreshes in between.
current.className = fresh.className;
current.title = fresh.title;
for (const selector of ["span", "strong", "small"]) {
const target = current.querySelector(selector);
const text = fresh.querySelector(selector).textContent;
if (target.textContent !== text) target.textContent = text;
}
continue;
} else if (current?.outerHTML === fresh.outerHTML) continue;
if (current) current.replaceWith(fresh);
else state.root.appendChild(fresh);
}
const currentPeriodSelect = state.root.querySelector("[data-hockey-current-period]");
if (!currentPeriodSelect || currentPeriodSelect.dataset.periodBound === "1") return;
currentPeriodSelect.dataset.periodBound = "1";
window.dispatchEvent(new CustomEvent("ui-builder:controls-rendered", { detail: { root: state.root } }));
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("dblclick", (event) => {
if (!event.target.closest(".hockey-status-clock")) return;
event.preventDefault();
event.stopPropagation();
const value = snapshot();
const gameId = String(value.game?.external_id || value.game?.id || "");
if (!value.timer || !gameId || !window.HockeyMatchAccess?.canControl?.(gameId)) return;
window.UIBuilderRuntime?.openTimersEditor?.(state.timerId);
});
root.addEventListener("keydown", (event) => {
if (event.target !== root && !event.target.closest(".hockey-status-match")) return;
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(); 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 || "");
const context = controlContext();
if (!payload || (gameId && context.gameId !== gameId)) return;
state.controlReadRevision += 1;
state.controlGameId = context.gameId;
state.controlContextKey = context.key;
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);
})();