diff --git a/ai/tasks/done/TASK-145-match-detail-player-table-controls.md b/ai/tasks/done/TASK-145-match-detail-player-table-controls.md new file mode 100644 index 0000000..3d4ec82 --- /dev/null +++ b/ai/tasks/done/TASK-145-match-detail-player-table-controls.md @@ -0,0 +1,111 @@ +--- +id: TASK-145 +title: Add match detail player table controls +status: done +type: frontend +team: Frontend Senior +supporting_teams: [] +roadmap_item: foundation +priority: medium +--- + +# TASK-145 - Add match detail player table controls + +## Goal + +Keep inactive match-detail player rows low priority and add compact search, team +filtering and sorting controls to the player table. + +## Context + +The internal match detail page already exposes a compact player stat table and +expandable detail panels. Profile and lobby snapshot rows with no team and no +match activity can currently compete with real participants in that table, and +larger matches need client-side controls to find and compare players without +changing backend contracts. + +## Steps + +1. Inspect the listed files first. +2. Apply only the scoped match-detail player table change. +3. Validate the result and document relevant findings. + +## Files to Read First + +- `ai/architecture-index.md` +- `ai/repo-context.md` +- `ai/orchestrator/frontend-senior.md` +- `frontend/historico-partida.html` +- `frontend/assets/js/historico-partida.js` +- `frontend/assets/css/historico-scoreboard-detail.css` + +## Expected Files to Modify + +- `frontend/historico-partida.html` +- `frontend/assets/js/historico-partida.js` +- `frontend/assets/css/historico-scoreboard-detail.css` +- `ai/tasks/done/TASK-145-match-detail-player-table-controls.md` + +## Constraints + +- Preserve the compact main columns and click-to-open player detail panels. +- Keep Aliados and Eje row treatments and badges. +- Filter team labels through the existing localized team mapping. +- Do not reintroduce expanded stats, event data or backend changes as table columns. +- Keep frontend behavior responsive and browser-loaded without new dependencies. + +## Validation + +- `node --check frontend/assets/js/historico-partida.js` +- `node --check frontend/assets/js/historico.js` +- `node --check frontend/assets/js/historico-recent-live.js` +- `docker compose build --no-cache frontend` +- `docker compose up -d frontend` +- Manual match-detail verification for default inactive ordering, stat sorting, + name search, team filters and panel close-on-filter behavior. +- Review `git diff --name-only` against task scope. + +## Outcome + +Implemented. The match-detail player table now has client-side player-name +search, localized team filtering, sort-column and direction controls above the +existing compact table. Default row ordering keeps active participants first, +orders them by kills descending, deaths ascending and name, and sends rows with +unknown team, zero combat stats and no expanded match stats to the bottom. +Numeric explicit sorts retain that inactive demotion, while name/team sorts can +compare all visible rows and inactive rows stay visually muted. + +The table continues to render row/detail pairs together after control changes. +Player-name clicks still open the existing expanded detail panel, and search or +team-filter changes close any open panel before the visible rows refresh. + +Validation passed: + +- `node --check frontend/assets/js/historico-partida.js` +- `node --check frontend/assets/js/historico.js` +- `node --check frontend/assets/js/historico-recent-live.js` +- `docker compose build --no-cache frontend` +- `docker compose up -d frontend` +- Rendered fallback verification on + `historico-partida.html?server=comunidad-hispana-02&match=comunidad-hispana-02%3A1779315955%3A1779319098%3Akharkovwarfare` + for control visibility, row detail opening, close-on-search, player-name + search, Aliados/Eje/Sin equipo filters and Jugador/Equipo/K/D/TK/K-D/KPM + sort selectors. +- Rendered default ordering on the provided match showed 18 active rows before + 44 inactive/no-team rows; visible zero-kill rows with deaths stayed in the + active block before the inactive rows. +- Desktop and mobile controls screenshots were inspected outside the repo. + +Notes: + +- Browser plugin runtime tools were not exposed by tool discovery in this + session, so rendered validation used a temporary local headless + Chrome/Selenium fallback outside the repository. +- Browser console output on the tested route only reported the existing missing + `favicon.ico` request. + +## Change Budget + +- Prefer fewer than 5 modified files. +- Prefer changes under 200 lines when feasible. +- Split the work into follow-up tasks if limits are exceeded. diff --git a/frontend/assets/css/historico-scoreboard-detail.css b/frontend/assets/css/historico-scoreboard-detail.css index 3b2afbf..115b9d2 100644 --- a/frontend/assets/css/historico-scoreboard-detail.css +++ b/frontend/assets/css/historico-scoreboard-detail.css @@ -38,10 +38,66 @@ min-width: 760px; } +.historical-player-controls { + display: grid; + grid-template-columns: minmax(220px, 1fr) repeat(3, minmax(150px, 190px)); + gap: 10px; + align-items: end; +} + +.historical-player-controls[hidden] { + display: none; +} + +.historical-player-control { + display: grid; + gap: 6px; + min-width: 0; +} + +.historical-player-control span { + color: var(--muted); + font-size: 0.66rem; + font-weight: 900; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.historical-player-control input, +.historical-player-control select { + width: 100%; + min-height: 42px; + padding: 0 12px; + border: 1px solid rgba(159, 168, 141, 0.28); + border-radius: 8px; + background: rgba(10, 13, 10, 0.82); + color: var(--text); + font: inherit; +} + +.historical-player-control input::placeholder { + color: var(--muted); +} + +.historical-player-control input:focus-visible, +.historical-player-control select:focus-visible { + outline: 2px solid rgba(210, 182, 118, 0.72); + outline-offset: 1px; +} + .historical-player-row { outline: 0; } +.historical-player-row.is-inactive { + color: var(--muted); +} + +.historical-player-row.is-inactive .historical-player-row__details-button span:first-child { + color: var(--text-soft); + font-style: italic; +} + .historical-player-row:hover td, .historical-player-row:focus-within td, .historical-player-row.is-expanded td { @@ -302,6 +358,14 @@ } @media (max-width: 760px) { + .historical-player-controls { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .historical-player-control--search { + grid-column: 1 / -1; + } + .historical-player-stats-panel__header, .historical-player-stats-panel__grid { grid-template-columns: 1fr; diff --git a/frontend/assets/js/historico-partida.js b/frontend/assets/js/historico-partida.js index 74a0d63..aabeedc 100644 --- a/frontend/assets/js/historico-partida.js +++ b/frontend/assets/js/historico-partida.js @@ -14,6 +14,11 @@ document.addEventListener("DOMContentLoaded", () => { playersSection: document.getElementById("match-detail-players-section"), playersNote: document.getElementById("match-detail-players-note"), playersState: document.getElementById("match-detail-players-state"), + playerControls: document.getElementById("match-detail-player-controls"), + playerSearch: document.getElementById("match-detail-player-search"), + playerTeamFilter: document.getElementById("match-detail-player-team-filter"), + playerSort: document.getElementById("match-detail-player-sort"), + playerSortDirection: document.getElementById("match-detail-player-sort-direction"), playersTableShell: document.getElementById("match-detail-players-table-shell"), playersBody: document.getElementById("match-detail-players-body"), timelineSection: document.getElementById("match-detail-timeline-section"), @@ -206,29 +211,159 @@ function renderPlayerSection(item, nodes) { nodes.playersState, "No hay filas de jugador registradas para esta partida.", ); + nodes.playerControls.hidden = true; nodes.playersTableShell.hidden = true; nodes.playersBody.innerHTML = ""; return; } - nodes.playersNote.textContent = `${formatNumber(players.length)} jugadores con estadisticas locales.`; - nodes.playersState.hidden = true; - nodes.playersBody.innerHTML = players - .map((player, index) => renderPlayerRows(player, item, index)) - .join(""); - bindPlayerDetailRows(nodes.playersBody); + const state = { + search: "", + team: "all", + sort: "kills", + direction: "desc", + isDefaultSort: true, + }; + const renderRows = () => renderPlayerTable(item, players, state, nodes); + + nodes.playerSearch.value = ""; + nodes.playerTeamFilter.value = state.team; + nodes.playerSort.value = state.sort; + nodes.playerSortDirection.value = state.direction; + bindPlayerTableControls(nodes, state, renderRows); + renderRows(); + nodes.playerControls.hidden = false; nodes.playersTableShell.hidden = false; } -function renderPlayerRows(player, item, index) { +function bindPlayerTableControls(nodes, state, renderRows) { + nodes.playerControls.onsubmit = (event) => { + event.preventDefault(); + }; + nodes.playerSearch.oninput = () => { + closePlayerDetailRows(nodes.playersBody); + state.search = nodes.playerSearch.value; + renderRows(); + }; + nodes.playerTeamFilter.onchange = () => { + closePlayerDetailRows(nodes.playersBody); + state.team = nodes.playerTeamFilter.value; + renderRows(); + }; + nodes.playerSort.onchange = () => { + state.sort = nodes.playerSort.value; + state.isDefaultSort = false; + renderRows(); + }; + nodes.playerSortDirection.onchange = () => { + state.direction = nodes.playerSortDirection.value; + state.isDefaultSort = false; + renderRows(); + }; +} + +function renderPlayerTable(item, players, state, nodes) { + const visiblePlayers = getVisiblePlayers(players, item, state); + nodes.playersNote.textContent = + visiblePlayers.length === players.length + ? `${formatNumber(players.length)} jugadores con estadisticas locales.` + : `${formatNumber(visiblePlayers.length)} de ${formatNumber(players.length)} jugadores visibles.`; + nodes.playersState.hidden = visiblePlayers.length > 0; + if (!visiblePlayers.length) { + nodes.playersState.textContent = "No hay jugadores que coincidan con los controles activos."; + } + nodes.playersBody.innerHTML = visiblePlayers + .map((entry, index) => renderPlayerRows(entry.player, item, index, entry.inactive)) + .join(""); + bindPlayerDetailRows(nodes.playersBody); +} + +function getVisiblePlayers(players, item, state) { + const normalizedSearch = normalizeLookupText(state.search); + return players + .map((player) => ({ + player, + inactive: isInactiveMatchPlayer(player), + team: getTeamSideDisplay(player.team || player.team_side), + })) + .filter((entry) => { + const matchesTeam = state.team === "all" || entry.team.key === state.team; + const matchesName = + !normalizedSearch || + normalizeLookupText(entry.player.player_name).includes(normalizedSearch); + return matchesTeam && matchesName; + }) + .sort((a, b) => comparePlayerEntries(a, b, item, state)); +} + +function comparePlayerEntries(a, b, item, state) { + if (state.isDefaultSort) { + return ( + compareInactivePriority(a, b) || + compareNumericStat(b.player.kills, a.player.kills) || + compareNumericStat(a.player.deaths, b.player.deaths) || + comparePlayerNames(a.player, b.player) + ); + } + + if (!["name", "team"].includes(state.sort)) { + const inactivePriority = compareInactivePriority(a, b); + if (inactivePriority) { + return inactivePriority; + } + } + + const direction = state.direction === "asc" ? 1 : -1; + const compared = comparePlayerSortValue(a, b, item, state.sort); + return compared * direction || comparePlayerNames(a.player, b.player); +} + +function comparePlayerSortValue(a, b, item, sort) { + if (sort === "name") { + return comparePlayerNames(a.player, b.player); + } + if (sort === "team") { + return compareText(a.team.label, b.team.label); + } + if (sort === "deaths" || sort === "teamkills" || sort === "kills") { + return compareNumericStat(a.player[sort], b.player[sort]); + } + if (sort === "kd") { + return compareNumericStat(getKdRatioValue(a.player), getKdRatioValue(b.player)); + } + return compareNumericStat( + getKpmValue(a.player.kills, item.duration_seconds), + getKpmValue(b.player.kills, item.duration_seconds), + ); +} + +function compareInactivePriority(a, b) { + return Number(a.inactive) - Number(b.inactive); +} + +function comparePlayerNames(a, b) { + return compareText(getPlayerName(a), getPlayerName(b)); +} + +function compareText(a, b) { + return String(a || "").localeCompare(String(b || ""), "es", { + sensitivity: "base", + }); +} + +function compareNumericStat(a, b) { + return toSortableNumber(a) - toSortableNumber(b); +} + +function renderPlayerRows(player, item, index, inactive = false) { const team = getTeamSideDisplay(player.team || player.team_side); const rowId = `match-player-row-${index}`; const panelId = `match-player-panel-${index}`; - const playerName = player.player_name || player.name || "Jugador no identificado"; + const playerName = getPlayerName(player); const kpm = formatKpm(player.kills, item.duration_seconds); return `