diff --git a/hockey_data/static/admin-directories.js b/hockey_data/static/admin-directories.js index 6e53c19..f951000 100644 --- a/hockey_data/static/admin-directories.js +++ b/hockey_data/static/admin-directories.js @@ -45,6 +45,7 @@ mappingSqlPreview: null, mappingSqlDraft: null, mappingLoadErrors: {}, + mappingContextRefreshTimer: null, countries: null, players: null, referees: null, @@ -3173,6 +3174,16 @@ render(); }); + window.addEventListener("hockey:mapping-context-updated", () => { + if (!state.open || !isMappingSection()) return; + if (state.mappingContextRefreshTimer) clearTimeout(state.mappingContextRefreshTimer); + state.mappingContextRefreshTimer = setTimeout(async () => { + state.mappingContextRefreshTimer = null; + try { await loadMappingCatalog(); } catch (_) {} + renderMapping(); + }, 80); + }); + window.addEventListener("hockey:tournament-selected", () => { if (!state.open) return; if (state.section === "khl_site" && !isKhlTournamentSelected()) state.section = "teams"; diff --git a/tests/test_build78_penalty_mapping_context_auto_sync.py b/tests/test_build78_penalty_mapping_context_auto_sync.py new file mode 100644 index 0000000..c1d1b43 --- /dev/null +++ b/tests/test_build78_penalty_mapping_context_auto_sync.py @@ -0,0 +1,46 @@ +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +APP = (ROOT / "ui_builder/static/app.js").read_text(encoding="utf-8") +ADMIN = (ROOT / "hockey_data/static/admin-directories.js").read_text(encoding="utf-8") + + +def test_side_penalty_mapping_context_is_derived_from_active_penalties(): + start = APP.index("async function hockeySyncPenaltySideMappingContext") + end = APP.index("function penaltyTargetAssignmentKey", start) + snippet = APP[start:end] + assert 'sortedPenaltyEntries("home")[0]' in snippet + assert 'sortedPenaltyEntries("away")[0]' in snippet + for key in ( + "selected_home_penalty_id", + "selected_home_penalty_player_id", + "selected_home_penalty_player_db_id", + "selected_home_penalty_team_penalty", + "selected_away_penalty_id", + "selected_away_penalty_player_id", + "selected_away_penalty_player_db_id", + "selected_away_penalty_team_penalty", + ): + assert f"{key}:" in snippet + assert 'fetch("/api/hockey/context/batch"' in snippet + + +def test_penalty_context_sync_runs_when_penalty_changes_and_clears_on_finish(): + assert "hockeySyncPenaltySideMappingContext().catch(() => {});" in APP + assert APP.count("hockeySyncPenaltySideMappingContext({ force: true }).catch(() => {});") >= 3 + + +def test_saved_penalty_player_keeps_external_and_database_ids(): + start = APP.index("function hockeyCompactPlayer") + end = APP.index("function hockeyCompactInfraction", start) + snippet = APP[start:end] + assert "const externalId" in snippet + assert "const dbId" in snippet + assert "externalId," in snippet + assert "dbId," in snippet + + +def test_mapping_admin_refreshes_live_after_runtime_context_update(): + assert 'window.addEventListener("hockey:mapping-context-updated"' in ADMIN + assert "await loadMappingCatalog()" in ADMIN + assert "renderMapping();" in ADMIN diff --git a/ui_builder/static/app.js b/ui_builder/static/app.js index f012fac..f912639 100644 --- a/ui_builder/static/app.js +++ b/ui_builder/static/app.js @@ -83,6 +83,9 @@ vmixPenaltyMirrors: new Map(), activeHockeyVmixTimerSteps: new Set(), vmixPenaltyTargetAssignments: new Map(), + hockeyPenaltyMappingContextSignature: "", + hockeyPenaltyMappingContextPending: false, + hockeyPenaltyMappingContextQueued: false, vmixFinishOverlayTimers: new Map(), vmixStrengthMappingRefreshPending: false, vmixStrengthMappingRefreshQueued: null, @@ -4493,6 +4496,84 @@ function startCustomTooltips() { || Number(a.event.createdAt || 0) - Number(b.event.createdAt || 0)); } + function hockeyPenaltySideMappingDetail(item, side) { + if (!item?.event) return null; + const event = item.event; + const playerIds = hockeyPenaltyPlayerIdentifiers(item.component, event); + return { + penalty_id: String(event.external_id || event.id || ""), + player_id: String(playerIds.externalId || ""), + player_db_id: String(playerIds.dbId || ""), + team_penalty: Boolean(event.teamPenalty), + side: String(side || event.player?.side || event.side || ""), + }; + } + + async function hockeySyncPenaltySideMappingContext({ force = false } = {}) { + const gameId = String(hockeyTimerSelectedGameId() || "").trim(); + if (!gameId) return false; + + const home = hockeyPenaltySideMappingDetail(sortedPenaltyEntries("home")[0] || null, "home"); + const away = hockeyPenaltySideMappingDetail(sortedPenaltyEntries("away")[0] || null, "away"); + const values = { + selected_home_penalty_id: home?.penalty_id || "", + selected_home_penalty_player_id: home?.player_id || "", + selected_home_penalty_player_db_id: home?.player_db_id || "", + selected_home_penalty_team_penalty: home ? (home.team_penalty ? "1" : "0") : "", + selected_away_penalty_id: away?.penalty_id || "", + selected_away_penalty_player_id: away?.player_id || "", + selected_away_penalty_player_db_id: away?.player_db_id || "", + selected_away_penalty_team_penalty: away ? (away.team_penalty ? "1" : "0") : "", + }; + const signature = JSON.stringify([gameId, values]); + if (!force && signature === state.hockeyPenaltyMappingContextSignature) return false; + + if (state.hockeyPenaltyMappingContextPending) { + state.hockeyPenaltyMappingContextQueued = true; + return true; + } + + state.hockeyPenaltyMappingContextPending = true; + try { + const response = await fetch("/api/hockey/context/batch", { + method: "POST", cache: "no-store", credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + values, + context: { + game_id: gameId, + device_id: currentRuntimeVmixDeviceId(), + session_token: currentRuntimeHockeySessionToken(), + }, + }), + }); + let payload = {}; + try { payload = await response.json(); } catch (_) {} + if (!response.ok) { + throw new Error(errorDetailText(payload.detail, `HTTP ${response.status}`)); + } + state.hockeyPenaltyMappingContextSignature = signature; + window.UIBuilderRuntime?.patchData?.({ + hockey: { + active_penalties: { home, away }, + }, + }, { render: false }); + window.dispatchEvent(new CustomEvent("hockey:mapping-context-updated", { + detail: { game_id: gameId, values: clone(values) }, + })); + return true; + } catch (error) { + console.error("Penalty side Mapping context error", error); + return false; + } finally { + state.hockeyPenaltyMappingContextPending = false; + if (state.hockeyPenaltyMappingContextQueued) { + state.hockeyPenaltyMappingContextQueued = false; + hockeySyncPenaltySideMappingContext({ force: true }).catch(() => {}); + } + } + } + function penaltyTargetAssignmentKey(step, side, target) { return `${String(step?.id || "")}:${side}:${String(target?.id || "")}`; } @@ -6211,6 +6292,7 @@ function openTimerQuickEditor(focusActionId = "") { if (hockeyEventReady(event) && state.activeHockeyVmixTimerSteps.size) { rebalanceVmixPenaltyTargets({ force: true, hideUnused: false }).catch((error) => console.error("Penalty target rebalance error", error)); } + hockeySyncPenaltySideMappingContext().catch(() => {}); } function createHockeyPenaltyDraft(component, seed = {}, source = "manual") { @@ -6492,6 +6574,7 @@ function openTimerQuickEditor(focusActionId = "") { if (board.selectedPreviewEventIds?.[side] === event.id) board.selectedPreviewEventIds[side] = ""; persistHockeyBoard(component, board, true); refreshHockeyBoardNodes(component); + hockeySyncPenaltySideMappingContext({ force: true }).catch(() => {}); rebalanceVmixPenaltyTargets({ force: true, hideUnused: true }) .catch((error) => console.error("Penalty target finish rebalance error", error)) .finally(() => fireConfiguredTimerFinishActions("penalty", { side, component, event, remaining_on_side: remainingOnSide })); @@ -6526,6 +6609,7 @@ function openTimerQuickEditor(focusActionId = "") { ...hockeyPenaltyContext(event) }); state.vmixPenaltyMirrors.delete(penaltyMirrorKey(component, event)); + hockeySyncPenaltySideMappingContext({ force: true }).catch(() => {}); rebalanceVmixPenaltyTargets({ force: true, hideUnused: true }).catch((error) => console.error("Penalty target remove rebalance error", error)); return true; } @@ -6620,6 +6704,7 @@ function openTimerQuickEditor(focusActionId = "") { if (completed.has(board.selectedEventId)) board.selectedEventId = null; persistHockeyBoard(component, board, true); refreshHockeyBoardNodes(component); + hockeySyncPenaltySideMappingContext({ force: true }).catch(() => {}); const finishBySide = new Map(); completedEvents.forEach((event) => { const side = String(event.player?.side || event.side || "").toLowerCase(); @@ -10129,8 +10214,12 @@ function renderHockeyPenaltyDashboard(node, component, runtime) { function hockeyCompactPlayer(player) { if (!player || typeof player !== "object") return null; + const externalId = String(player.externalId || player.external_id || player.id || player.raw?.external_id || player.raw?.id || ""); + const dbId = String(player.dbId || player.db_id || player.database_id || player.raw?.db_id || player.raw?.database_id || ""); return { - id: String(player.id || ""), + id: externalId, + externalId, + dbId, side: player.side === "away" ? "away" : player.side === "home" ? "home" : "", number: String(player.number || ""), name: String(player.name || ""), @@ -10254,6 +10343,8 @@ function renderHockeyPenaltyDashboard(node, component, runtime) { refreshHockeyBoardNodes(boardComponent); } state.hockeyTimerGameId = String(gameId || ""); + state.hockeyPenaltyMappingContextSignature = ""; + hockeySyncPenaltySideMappingContext({ force: true }).catch(() => {}); } async function hockeyPersistGameTimers(gameId, { keepalive = false, force = false } = {}) {