diff --git a/app.py b/app.py index 2f69d58..92b07e7 100644 --- a/app.py +++ b/app.py @@ -29,7 +29,7 @@ from ui_builder import install_ui_builder from khl_site.khl_data_center import APP as khl_site_app BASE_DIR = Path(__file__).resolve().parent -BUILD_VERSION = "2026.08.26.8" +BUILD_VERSION = "2026.08.26.9" # compatibility: BUILD_VERSION = "2026.08.26.1" # compatibility: BUILD_VERSION = "2026.08.25.1" # compatibility: BUILD_VERSION = "2026.08.24.15" diff --git a/hockey_data/models.py b/hockey_data/models.py index 37b3130..8837a43 100644 --- a/hockey_data/models.py +++ b/hockey_data/models.py @@ -683,6 +683,12 @@ class GameRoster(Base): position_ru: Mapped[str] = mapped_column(String(64), nullable=False, default="") position_en: Mapped[str] = mapped_column(String(64), nullable=False, default="") line_number: Mapped[str] = mapped_column(String(16), nullable=False, default="") + # BUILD122: the API line stays available as a baseline, while operators may + # override the effective line/order without a later Stat2TV refresh erasing it. + api_line_number: Mapped[str] = mapped_column(String(16), nullable=False, default="") + api_line_sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + line_sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + line_manual: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) captain_role: Mapped[str] = mapped_column(String(32), nullable=False, default="") lineup_status: Mapped[str] = mapped_column(String(64), nullable=False, default="") raw_payload: Mapped[str] = mapped_column(Text, nullable=False, default="") diff --git a/hockey_data/router.py b/hockey_data/router.py index ad9c0ca..52edc87 100644 --- a/hockey_data/router.py +++ b/hockey_data/router.py @@ -123,6 +123,23 @@ class GameScorePayload(BaseModel): language: str = Field(default="ru", pattern="^(ru|en)$") +class GameLineAssignmentPayload(BaseModel): + model_config = ConfigDict(extra="ignore") + + player_external_id: str = Field(min_length=1, max_length=64) + line_number: str = Field(default="", max_length=16) + sort_order: int = Field(default=0, ge=0, le=999) + + +class GameLinesPayload(BaseModel): + model_config = ConfigDict(extra="ignore") + + side: str = Field(pattern="^(home|away)$") + assignments: list[GameLineAssignmentPayload] = Field(default_factory=list) + reset: bool = False + language: str = Field(default="ru", pattern="^(ru|en)$") + + class ShootoutSetupPayload(BaseModel): initial_attempts: int = Field(default=3) reset: bool = False @@ -1030,6 +1047,30 @@ def create_hockey_router( except ValueError as error: raise HTTPException(status_code=400, detail=str(error)) from error + @router.put("/games/{external_id}/lines") + async def update_game_lines( + external_id: str, + payload: GameLinesPayload, + user: HockeyUser = Depends(auth_dependency), + ) -> dict[str, Any]: + try: + if payload.reset: + return service.reset_game_lines( + external_id, + side=payload.side, + updated_by=user.id, + language=payload.language, + ) + return service.save_game_lines( + external_id, + side=payload.side, + assignments=[item.model_dump() for item in payload.assignments], + updated_by=user.id, + language=payload.language, + ) + except ValueError as error: + raise HTTPException(status_code=400, detail=str(error)) from error + @router.put("/games/{external_id}/control/values") async def update_game_values( external_id: str, diff --git a/hockey_data/service.py b/hockey_data/service.py index 3076bf9..63365d5 100644 --- a/hockey_data/service.py +++ b/hockey_data/service.py @@ -3050,6 +3050,7 @@ class HockeyDataService: for row in existing_rosters } seen_rosters: set[tuple[str, str]] = set() + api_line_order: dict[tuple[str, str], int] = {} for entry in details.rosters: profile = player_profiles.get(entry.player_external_id) if profile is None: @@ -3080,7 +3081,14 @@ class HockeyDataService: roster.role = entry.role roster.position_ru = entry.position_ru roster.position_en = entry.position_en - roster.line_number = entry.line_number + api_line = str(entry.line_number or "").strip() + api_order_key = (entry.side, api_line) + api_line_order[api_order_key] = api_line_order.get(api_order_key, 0) + 1 + roster.api_line_number = api_line + roster.api_line_sort_order = api_line_order[api_order_key] + if not bool(roster.line_manual): + roster.line_number = api_line + roster.line_sort_order = roster.api_line_sort_order roster.captain_role = entry.captain_role roster.lineup_status = entry.lineup_status roster.raw_payload = entry.raw_payload @@ -4917,6 +4925,10 @@ class HockeyDataService: ), "role": roster.role, "line": roster.line_number, + "line_sort_order": int(roster.line_sort_order or 0), + "line_manual": bool(roster.line_manual), + "api_line": roster.api_line_number, + "api_line_sort_order": int(roster.api_line_sort_order or 0), "captain_role": roster.captain_role, "lineup_status": roster.lineup_status, "birth_date": ( @@ -4937,12 +4949,29 @@ class HockeyDataService: ) for items in serialized_players.values(): - items.sort( - key=lambda item: ( - self._number_sort_value(str(item["number"])), - str(item["name"]), + has_lines = any(str(item.get("line") or "").strip() for item in items) + if has_lines: + def line_sort_value(item: dict[str, Any]) -> tuple[Any, ...]: + raw_line = str(item.get("line") or "").strip() + try: + line_number = int(raw_line) + except (TypeError, ValueError): + line_number = 10_000 if not raw_line else 9_000 + order = int(item.get("line_sort_order") or 0) + return ( + line_number, + order if order > 0 else 10_000, + self._number_sort_value(str(item.get("number") or "")), + str(item.get("name") or ""), + ) + items.sort(key=line_sort_value) + else: + items.sort( + key=lambda item: ( + self._number_sort_value(str(item["number"])), + str(item["name"]), + ) ) - ) serialized_officials: list[dict[str, Any]] = [] role_labels = { @@ -6102,6 +6131,102 @@ class HockeyDataService: raise ValueError("Матч не найден") return result + def save_game_lines( + self, + game_external_id: str, + *, + side: str, + assignments: list[dict[str, Any]], + updated_by: str = "", + language: str = "ru", + ) -> dict[str, Any]: + """Persist the operator's effective line order for one team. + + BUILD122 deliberately stores the result on ``hockey_game_rosters`` so + broadcast SQL can read the same roster table directly. ``line_manual`` + protects these values from later Stat2TV roster refreshes while the + parallel api_* fields keep the newest API baseline available for reset. + """ + side = str(side or "").strip().lower() + if side not in {"home", "away"}: + raise ValueError("Неизвестная сторона команды") + + normalised: dict[str, tuple[str, int]] = {} + for raw in assignments or []: + player_id = str(raw.get("player_external_id") or raw.get("player_id") or "").strip() + if not player_id: + continue + line = str(raw.get("line_number") or "").strip() + if line: + if not line.isdigit() or not (1 <= int(line) <= 99): + raise ValueError("Номер звена должен быть от 1 до 99") + line = str(int(line)) + try: + sort_order = max(0, int(raw.get("sort_order") or 0)) + except (TypeError, ValueError): + sort_order = 0 + normalised[player_id] = (line, sort_order) + + with self.database.session() as session: + game = session.scalar(select(Game).where(Game.external_id == str(game_external_id))) + if game is None: + raise ValueError("Матч не найден") + rosters = list(session.scalars(select(GameRoster).where(and_( + GameRoster.game_external_id == game.external_id, + GameRoster.side == side, + )))) + roster_by_id = {str(row.player_external_id): row for row in rosters} + unknown = sorted(set(normalised) - set(roster_by_id)) + if unknown: + raise ValueError(f"Игрок не найден в составе: {unknown[0]}") + for player_id, (line, sort_order) in normalised.items(): + roster = roster_by_id[player_id] + # First manual edit after upgrading from an older build: keep the + # already stored API line as a reset baseline before overwriting it. + if not str(roster.api_line_number or "").strip() and str(roster.line_number or "").strip(): + roster.api_line_number = str(roster.line_number or "").strip() + roster.api_line_sort_order = int(roster.line_sort_order or 0) + roster.line_number = line + roster.line_sort_order = sort_order + roster.line_manual = True + roster.synced_at = datetime.utcnow() + + result = self.game_details(game_external_id, language=language) + if result is None: + raise ValueError("Матч не найден") + return result + + def reset_game_lines( + self, + game_external_id: str, + *, + side: str, + updated_by: str = "", + language: str = "ru", + ) -> dict[str, Any]: + """Return one team's effective line layout to the latest API baseline.""" + side = str(side or "").strip().lower() + if side not in {"home", "away"}: + raise ValueError("Неизвестная сторона команды") + with self.database.session() as session: + game = session.scalar(select(Game).where(Game.external_id == str(game_external_id))) + if game is None: + raise ValueError("Матч не найден") + rosters = list(session.scalars(select(GameRoster).where(and_( + GameRoster.game_external_id == game.external_id, + GameRoster.side == side, + )))) + for roster in rosters: + roster.line_number = str(roster.api_line_number or "").strip() + roster.line_sort_order = int(roster.api_line_sort_order or 0) + roster.line_manual = False + roster.synced_at = datetime.utcnow() + + result = self.game_details(game_external_id, language=language) + if result is None: + raise ValueError("Матч не найден") + return result + def set_game_period( self, game_external_id: str, diff --git a/ui_builder/static/app.js b/ui_builder/static/app.js index 8aba320..576267b 100644 --- a/ui_builder/static/app.js +++ b/ui_builder/static/app.js @@ -2597,7 +2597,9 @@ function startCustomTooltips() { // BUILD86: "Заготовки" is always the final top-level runtime tab. // Reorder it on every config normalisation as older published configs may // already contain the tab near "Игра" from BUILD84/85. - state.config.tabs = state.config.tabs.filter((tab) => tab?.id !== "prepared_titles"); + state.config.tabs = state.config.tabs.filter((tab) => !["lines", "prepared_titles"].includes(tab?.id)); + // BUILD122: line/five sorting is a first-class operator workspace. + state.config.tabs.push({ id: "lines", label: "Пятёрки" }); state.config.tabs.push({ id: "prepared_titles", label: "Заготовки" }); if (state.activeTab === "prematch") state.activeTab = state.config.tabs.find((tab) => tab.id === "main")?.id || state.config.tabs[0].id; } @@ -6817,6 +6819,10 @@ function openTimerQuickEditor(focusActionId = "") { name: get("nameField", `Игрок ${index + 1}`), position: get("positionField", ""), role: String(row?.role || ""), + line: String(row?.line || row?.line_number || "").trim(), + lineSortOrder: Number(row?.line_sort_order || 0), + lineManual: Boolean(row?.line_manual), + apiLine: String(row?.api_line || row?.api_line_number || "").trim(), raw: clone(row) }; } @@ -6825,19 +6831,33 @@ function openTimerQuickEditor(focusActionId = "") { const props = component.props || {}; const rows = getByPath(state.data, side === "home" ? props.homePlayersPath : props.awayPlayersPath); const limit = Math.max(1, Number(props.rosterLimit) || 40); - return (Array.isArray(rows) ? rows : []) - .map((row, index) => normalizeHockeyPlayer(component, row, side, index)) - .sort((left, right) => { - const leftNumber = String(left.number || "").replace(/^#/, ""); - const rightNumber = String(right.number || "").replace(/^#/, ""); - if (!leftNumber && rightNumber) return 1; - if (leftNumber && !rightNumber) return -1; - return leftNumber.localeCompare(rightNumber, undefined, { - numeric: true, - sensitivity: "base", - }) || left.name.localeCompare(right.name); - }) - .slice(0, limit); + const players = (Array.isArray(rows) ? rows : []) + .map((row, index) => normalizeHockeyPlayer(component, row, side, index)); + const hasLineData = players.some((player) => String(player.line || "").trim()); + players.sort((left, right) => { + if (hasLineData) { + const lineValue = (player) => { + const raw = String(player.line || "").trim(); + if (!raw) return 10000; + const number = Number(raw); + return Number.isFinite(number) ? number : 9000; + }; + const lineDiff = lineValue(left) - lineValue(right); + if (lineDiff) return lineDiff; + const leftOrder = Number(left.lineSortOrder || 0) || 10000; + const rightOrder = Number(right.lineSortOrder || 0) || 10000; + if (leftOrder !== rightOrder) return leftOrder - rightOrder; + } + const leftNumber = String(left.number || "").replace(/^#/, ""); + const rightNumber = String(right.number || "").replace(/^#/, ""); + if (!leftNumber && rightNumber) return 1; + if (leftNumber && !rightNumber) return -1; + return leftNumber.localeCompare(rightNumber, undefined, { + numeric: true, + sensitivity: "base", + }) || left.name.localeCompare(right.name); + }); + return players.slice(0, limit); } function hockeyRosterCounts(players) { @@ -6860,6 +6880,296 @@ function openTimerQuickEditor(focusActionId = "") { return `${counts.total}${counts.forwards}${counts.defenders}${counts.goalkeepers}`; } + + // BUILD122 — operator line/five sorting. + function hockeyLinesGame() { + return getByPath(state.data, "hockey.selected_game") || null; + } + + function hockeyLinesPlayers(side) { + const game = hockeyLinesGame(); + const rows = game?.[side]?.players; + return Array.isArray(rows) ? rows : []; + } + + function hockeyLineNumericValue(value) { + const raw = String(value || "").trim(); + if (!raw) return 10000; + const number = Number(raw); + return Number.isFinite(number) ? number : 9000; + } + + function hockeyLinePlayerNumberValue(player) { + const raw = String(player?.number || "").replace(/^#/, "").trim(); + const number = Number(raw); + return Number.isFinite(number) ? number : 10000; + } + + function hockeyLinePlayersSorted(side, lineNumber) { + const line = String(lineNumber || "").trim(); + return hockeyLinesPlayers(side) + .filter((player) => String(player?.line || "").trim() === line) + .slice() + .sort((left, right) => { + const leftOrder = Number(left?.line_sort_order || 0) || 10000; + const rightOrder = Number(right?.line_sort_order || 0) || 10000; + return leftOrder - rightOrder + || hockeyLinePlayerNumberValue(left) - hockeyLinePlayerNumberValue(right) + || String(left?.name || "").localeCompare(String(right?.name || ""), undefined, { sensitivity: "base" }); + }); + } + + function hockeyLineVisibleCount(side) { + const key = `hockeyLinesVisible:${side}`; + const maxAssigned = hockeyLinesPlayers(side).reduce((maximum, player) => { + const value = Number(String(player?.line || "").trim()); + return Number.isFinite(value) ? Math.max(maximum, value) : maximum; + }, 0); + const current = Number(state.formValues[key] || 0); + const next = Math.max(4, maxAssigned, current); + state.formValues[key] = next; + return next; + } + + function hockeyReindexLine(side, lineNumber) { + hockeyLinePlayersSorted(side, lineNumber).forEach((player, index) => { + player.line_sort_order = index + 1; + }); + } + + function hockeyMoveLinePlayer(side, playerId, targetLine, beforePlayerId = "") { + const players = hockeyLinesPlayers(side); + const player = players.find((item) => String(item?.external_id || item?.id || "") === String(playerId || "")); + if (!player) return false; + const sourceLine = String(player.line || "").trim(); + const nextLine = String(targetLine || "").trim(); + const targetPlayers = hockeyLinePlayersSorted(side, nextLine).filter((item) => item !== player); + let insertIndex = targetPlayers.length; + if (beforePlayerId) { + const found = targetPlayers.findIndex((item) => String(item?.external_id || item?.id || "") === String(beforePlayerId)); + if (found >= 0) insertIndex = found; + } + player.line = nextLine; + targetPlayers.splice(insertIndex, 0, player); + targetPlayers.forEach((item, index) => { item.line_sort_order = index + 1; }); + if (sourceLine !== nextLine) hockeyReindexLine(side, sourceLine); + return true; + } + + async function hockeySaveLineAssignments(side) { + const game = hockeyLinesGame(); + const gameId = String(game?.external_id || game?.id || "").trim(); + if (!gameId) throw new Error("Сначала выберите матч"); + const assignments = hockeyLinesPlayers(side).map((player) => ({ + player_external_id: String(player?.external_id || player?.id || ""), + line_number: String(player?.line || "").trim(), + sort_order: Number(player?.line_sort_order || 0), + })).filter((item) => item.player_external_id); + const payload = await hockeyGameControlRequest(`/games/${encodeURIComponent(gameId)}/lines`, { + method: "PUT", + body: JSON.stringify({ + side, + assignments, + language: hockeyGameControlLanguage(), + }), + }); + state.data.hockey ||= {}; + const current = state.data.hockey.selected_game || {}; + current[side] ||= {}; + current[side].players = Array.isArray(payload?.[side]?.players) ? payload[side].players : current[side].players; + if (payload?.player_statistics) current.player_statistics = payload.player_statistics; + if (payload?.season_player_statistics) current.season_player_statistics = payload.season_player_statistics; + state.data.hockey.selected_game = current; + return payload; + } + + async function hockeyResetLineAssignments(side) { + const game = hockeyLinesGame(); + const gameId = String(game?.external_id || game?.id || "").trim(); + if (!gameId) throw new Error("Сначала выберите матч"); + const payload = await hockeyGameControlRequest(`/games/${encodeURIComponent(gameId)}/lines`, { + method: "PUT", + body: JSON.stringify({ + side, + assignments: [], + reset: true, + language: hockeyGameControlLanguage(), + }), + }); + state.data.hockey ||= {}; + const current = state.data.hockey.selected_game || {}; + current[side] ||= {}; + current[side].players = Array.isArray(payload?.[side]?.players) ? payload[side].players : current[side].players; + if (payload?.player_statistics) current.player_statistics = payload.player_statistics; + if (payload?.season_player_statistics) current.season_player_statistics = payload.season_player_statistics; + state.data.hockey.selected_game = current; + state.formValues[`hockeyLinesVisible:${side}`] = 0; + return payload; + } + + function hockeyLinePlayerCard(side, player) { + const node = div("hockey-line-player"); + const playerId = String(player?.external_id || player?.id || ""); + node.draggable = true; + node.dataset.playerId = playerId; + node.dataset.side = side; + node.innerHTML = ` + ⋮⋮ + ${escapeHtml(player?.number ? `#${player.number}` : "—")} + ${escapeHtml(player?.name || "Игрок")} + ${escapeHtml(player?.position || "")} + `; + node.addEventListener("dragstart", (event) => { + const payload = JSON.stringify({ side, playerId }); + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData("application/x-hockey-line-player", payload); + event.dataTransfer.setData("text/plain", payload); + node.classList.add("is-dragging"); + }); + node.addEventListener("dragend", () => node.classList.remove("is-dragging")); + return node; + } + + function hockeyReadLineDrag(event) { + try { + const raw = event.dataTransfer?.getData("application/x-hockey-line-player") || event.dataTransfer?.getData("text/plain") || ""; + const payload = JSON.parse(raw); + if (!payload?.side || !payload?.playerId) return null; + return payload; + } catch (_) { + return null; + } + } + + function hockeyLineDropZone(side, lineNumber, players) { + const zone = div("hockey-line-dropzone"); + zone.dataset.side = side; + zone.dataset.line = String(lineNumber || ""); + if (!players.length) zone.innerHTML = `Перетащите игрока сюда`; + players.forEach((player) => { + const card = hockeyLinePlayerCard(side, player); + card.addEventListener("dragover", (event) => { + event.preventDefault(); + event.stopPropagation(); + event.dataTransfer.dropEffect = "move"; + card.classList.add("is-drop-before"); + }); + card.addEventListener("dragleave", () => card.classList.remove("is-drop-before")); + card.addEventListener("drop", async (event) => { + event.preventDefault(); + event.stopPropagation(); + card.classList.remove("is-drop-before"); + const dragged = hockeyReadLineDrag(event); + if (!dragged || dragged.side !== side) return; + if (!hockeyMoveLinePlayer(side, dragged.playerId, lineNumber, String(player?.external_id || player?.id || ""))) return; + renderRuntime(); + try { + await hockeySaveLineAssignments(side); + renderRuntime(); + } catch (error) { + toast(`Не удалось сохранить пятёрки: ${error.message}`, true); + } + }); + zone.appendChild(card); + }); + zone.addEventListener("dragover", (event) => { + const dragged = hockeyReadLineDrag(event); + if (!dragged || dragged.side !== side) return; + event.preventDefault(); + event.dataTransfer.dropEffect = "move"; + zone.classList.add("is-over"); + }); + zone.addEventListener("dragleave", (event) => { + if (!zone.contains(event.relatedTarget)) zone.classList.remove("is-over"); + }); + zone.addEventListener("drop", async (event) => { + if (event.target.closest(".hockey-line-player")) return; + event.preventDefault(); + zone.classList.remove("is-over"); + const dragged = hockeyReadLineDrag(event); + if (!dragged || dragged.side !== side) return; + if (!hockeyMoveLinePlayer(side, dragged.playerId, lineNumber)) return; + renderRuntime(); + try { + await hockeySaveLineAssignments(side); + renderRuntime(); + } catch (error) { + toast(`Не удалось сохранить пятёрки: ${error.message}`, true); + } + }); + return zone; + } + + function hockeyLineTeamColumn(side) { + const game = hockeyLinesGame() || {}; + const team = game?.[side] || {}; + const column = div(`hockey-lines-team side-${side}`); + const players = hockeyLinesPlayers(side); + const manualCount = players.filter((player) => Boolean(player?.line_manual)).length; + const header = div("hockey-lines-team-header"); + header.innerHTML = ` +
${side === "home" ? "ЛЕВАЯ КОМАНДА" : "ПРАВАЯ КОМАНДА"}${escapeHtml(team?.name || (side === "home" ? "Хозяева" : "Гости"))}
+ ${manualCount ? `ручн. ${manualCount}` : "API"} + `; + const actions = div("hockey-lines-actions"); + const addButton = document.createElement("button"); + addButton.type = "button"; + addButton.textContent = "+ Звено"; + addButton.addEventListener("click", () => { + state.formValues[`hockeyLinesVisible:${side}`] = hockeyLineVisibleCount(side) + 1; + renderRuntime(); + }); + const resetButton = document.createElement("button"); + resetButton.type = "button"; + resetButton.textContent = "Вернуть из API"; + resetButton.title = "Снять ручные назначения и вернуть последнее звено/порядок, полученные из API"; + resetButton.addEventListener("click", async () => { + resetButton.disabled = true; + try { + await hockeyResetLineAssignments(side); + toast(`Пятёрки ${side === "home" ? "HOME" : "AWAY"}: восстановлены из API`); + renderRuntime(); + } catch (error) { + resetButton.disabled = false; + toast(`Не удалось вернуть пятёрки: ${error.message}`, true); + } + }); + actions.append(addButton, resetButton); + column.append(header, actions); + + const unassigned = div("hockey-line-card is-unassigned"); + const unassignedPlayers = hockeyLinePlayersSorted(side, ""); + unassigned.innerHTML = `
Без звена${unassignedPlayers.length}
`; + unassigned.appendChild(hockeyLineDropZone(side, "", unassignedPlayers)); + column.appendChild(unassigned); + + const count = hockeyLineVisibleCount(side); + for (let line = 1; line <= count; line += 1) { + const linePlayers = hockeyLinePlayersSorted(side, String(line)); + const card = div("hockey-line-card"); + card.innerHTML = `
Звено ${line}${linePlayers.length}
`; + card.appendChild(hockeyLineDropZone(side, String(line), linePlayers)); + column.appendChild(card); + } + return column; + } + + function renderHockeyLinesWorkspace() { + const workspace = div("hockey-lines-workspace"); + const game = hockeyLinesGame(); + if (!game) { + workspace.innerHTML = `
Выберите матчПосле загрузки матча здесь появятся составы для сортировки по звеньям.
`; + return workspace; + } + const intro = div("hockey-lines-intro"); + intro.innerHTML = `
Пятёрки / звеньяПеретаскивайте игроков между звеньями и меняйте порядок внутри звена. Изменения сохраняются в базе автоматически.
SQL: hockey_game_rosters.line_number + line_sort_order`; + workspace.append(intro); + const teams = div("hockey-lines-columns"); + teams.append(hockeyLineTeamColumn("home"), hockeyLineTeamColumn("away")); + workspace.appendChild(teams); + return workspace; + } + function hockeyTeamName(component, side) { const path = side === "home" ? component.props?.homeTeamPath : component.props?.awayTeamPath; return formatValue(getByPath(state.data, path), side === "home" ? "Хозяева" : "Гости"); @@ -13272,7 +13582,7 @@ function renderHockeyPenaltyDashboard(node, component, runtime) { } function runtimeHasFixedHockeyCanvas() { - if (state.activeTab === "prepared_titles") return true; + if (["prepared_titles", "lines"].includes(state.activeTab)) return true; return state.config.components.some((component) => [ "hockey_penalty_dashboard", @@ -13382,6 +13692,8 @@ function renderHockeyPenaltyDashboard(node, component, runtime) { if (state.preparedTitlesLoadedKey !== hockeyPreparedWorkspaceKey() && !state.preparedTitlesLoading) { setTimeout(() => hockeyLoadPreparedTitlesWorkspace({ force: true }).catch(() => {}), 0); } + } else if (state.activeTab === "lines") { + el.runtimeStage.appendChild(renderHockeyLinesWorkspace()); } else { state.config.components.filter((component) => !component.hidden && component.props?.runtimeHidden !== true && state.runtimeVisibility[component.action_id] !== false && isEffectivelyOnActiveTab(component)).sort((a, b) => Number(a.z) - Number(b.z)).forEach((component) => { const wrapper = document.createElement("div"); wrapper.className = "runtime-component"; Object.assign(wrapper.style, { left: `${component.x}px`, top: `${component.y}px`, width: `${component.w}px`, height: `${component.h}px`, zIndex: String(component.z) }); wrapper.appendChild(renderComponent(component, true)); el.runtimeStage.appendChild(wrapper); diff --git a/ui_builder/static/styles.css b/ui_builder/static/styles.css index 6081c90..0801929 100644 --- a/ui_builder/static/styles.css +++ b/ui_builder/static/styles.css @@ -7778,3 +7778,26 @@ body.hockey-navigation-open .runtime-viewport.has-hockey-pbp { gap: 14px !import .quick-score-team{display:none} .quick-score-control{padding-inline:4px} } + +/* BUILD122 — drag-and-drop hockey line/five sorter. */ +.hockey-lines-workspace{ + position:absolute;inset:0;display:grid;grid-template-rows:auto minmax(0,1fr);gap:10px; + padding:14px;box-sizing:border-box;color:#dcecff;background:linear-gradient(180deg,#0a1522 0%,#09131f 100%);overflow:hidden; +} +.hockey-lines-intro{ + min-height:54px;display:flex;align-items:center;justify-content:space-between;gap:16px;padding:10px 13px; + border:1px solid #263f56;border-radius:11px;background:#0c1b2a; +} +.hockey-lines-intro>div{min-width:0;display:grid;gap:3px}.hockey-lines-intro strong{font-size:15px}.hockey-lines-intro span{color:#7f96ad;font-size:8px}.hockey-lines-intro small{color:#56e3c1;font-size:8px;font-weight:850;white-space:nowrap} +.hockey-lines-columns{min-height:0;display:grid;grid-template-columns:1fr 1fr;gap:12px;overflow:hidden} +.hockey-lines-team{min-width:0;min-height:0;display:flex;flex-direction:column;gap:8px;padding:10px;border:1px solid #243a50;border-radius:12px;background:#0b1725;overflow-y:auto} +.hockey-lines-team.side-home{box-shadow:inset 3px 0 0 #4d9cff}.hockey-lines-team.side-away{box-shadow:inset -3px 0 0 #ff5f79} +.hockey-lines-team-header{display:flex;align-items:center;justify-content:space-between;gap:10px;position:sticky;top:-10px;z-index:3;padding:7px 4px 8px;background:#0b1725} +.hockey-lines-team-header>div{min-width:0;display:grid;gap:2px}.hockey-lines-team-header span{color:#70879f;font-size:7px;font-weight:950;letter-spacing:.09em}.hockey-lines-team-header strong{overflow:hidden;font-size:13px;text-overflow:ellipsis;white-space:nowrap}.hockey-lines-team-header em{padding:4px 8px;border:1px solid #2b475f;border-radius:999px;color:#80a0ba;background:#102235;font-size:7px;font-style:normal;font-weight:900} +.hockey-lines-actions{display:flex;gap:6px}.hockey-lines-actions button{min-height:28px;padding:0 10px;border:1px solid #31516a;border-radius:7px;color:#cfe3f3;background:#10263a;font-size:8px;font-weight:900;cursor:pointer}.hockey-lines-actions button:hover{border-color:#54dcbf;color:#fff}.hockey-lines-actions button:disabled{opacity:.45;cursor:default} +.hockey-line-card{display:grid;gap:6px;padding:7px;border:1px solid #22394f;border-radius:10px;background:#0e1d2c}.hockey-line-card.is-unassigned{border-style:dashed;background:#0a1622} +.hockey-line-card>header{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:0 2px}.hockey-line-card>header strong{font-size:9px}.hockey-line-card>header span{display:grid;place-items:center;min-width:22px;height:18px;padding:0 5px;border-radius:999px;color:#7e97af;background:#14283b;font-size:7px;font-weight:950} +.hockey-line-dropzone{min-height:38px;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:5px;padding:4px;border:1px solid transparent;border-radius:8px;transition:border-color .12s,background .12s} +.hockey-line-dropzone>span{grid-column:1/-1;align-self:center;color:#4f6880;font-size:8px;text-align:center}.hockey-line-dropzone.is-over{border-color:#54dcbf;background:rgba(84,220,191,.06)} +.hockey-line-player{min-width:0;display:grid;grid-template-columns:12px 31px minmax(0,1fr);grid-template-rows:auto auto;column-gap:5px;align-items:center;padding:5px 6px;border:1px solid #29445c;border-radius:8px;background:#102235;cursor:grab;transition:transform .1s,border-color .1s,opacity .1s}.hockey-line-player:hover{border-color:#47708f;transform:translateY(-1px)}.hockey-line-player.is-dragging{opacity:.35}.hockey-line-player.is-drop-before{border-color:#54e5c4;box-shadow:inset 3px 0 0 #54e5c4}.hockey-line-drag{grid-row:1/3;color:#58738c;font-size:9px;letter-spacing:-2px}.hockey-line-player b{grid-row:1/3;display:grid;place-items:center;min-height:27px;border-radius:6px;color:#dcecff;background:#0a1724;font-size:8px}.hockey-line-player strong{min-width:0;overflow:hidden;font-size:9px;text-overflow:ellipsis;white-space:nowrap}.hockey-line-player small{min-width:0;overflow:hidden;color:#6f879e;font-size:7px;text-overflow:ellipsis;white-space:nowrap} +.hockey-lines-empty{position:absolute;inset:0;display:grid;place-content:center;gap:6px;color:#7890a8;text-align:center}.hockey-lines-empty strong{color:#d9e8f6;font-size:17px}.hockey-lines-empty span{font-size:9px}