From 6170a7af916c363cf6ec1ea9b692b25a5e06e2bc Mon Sep 17 00:00:00 2001 From: devRaGonSa Date: Thu, 21 May 2026 10:01:40 +0200 Subject: [PATCH] feat: add player table sorting and filters --- ...-145-match-detail-player-table-controls.md | 111 +++++++++ .../css/historico-scoreboard-detail.css | 64 ++++++ frontend/assets/js/historico-partida.js | 213 ++++++++++++++++-- frontend/historico-partida.html | 44 ++++ 4 files changed, 418 insertions(+), 14 deletions(-) create mode 100644 ai/tasks/done/TASK-145-match-detail-player-table-controls.md 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 ` @@ -301,6 +436,37 @@ function bindPlayerDetailRows(playersBody) { }); } +function closePlayerDetailRows(playersBody) { + [...playersBody.querySelectorAll(".historical-player-row")].forEach((row) => { + const button = row.querySelector(".historical-player-row__details-button"); + const detailRow = row.nextElementSibling; + if (!button || !detailRow?.classList.contains("historical-player-detail-row")) { + return; + } + row.classList.remove("is-expanded"); + detailRow.classList.remove("is-open"); + button.setAttribute("aria-expanded", "false"); + }); +} + +function getPlayerName(player) { + return player.player_name || player.name || "Jugador no identificado"; +} + +function isInactiveMatchPlayer(player) { + const team = getTeamSideDisplay(player.team || player.team_side); + return ( + team.key === "unknown" && + toSortableNumber(player.kills) === 0 && + toSortableNumber(player.deaths) === 0 && + toSortableNumber(player.teamkills) === 0 && + getKdRatioValue(player) === 0 && + !hasNamedCounts(player.top_weapons) && + !hasNamedCounts(player.most_killed) && + !hasNamedCounts(player.death_by) + ); +} + function renderPlayerStatsPanel(player, item, context) { const matchups = buildPlayerDirectMatchups(player); const hasExpandedStats = @@ -606,18 +772,32 @@ function formatOptionalNumber(value) { } function formatKdRatio(player) { + if ( + !Number.isFinite(Number(player.kd_ratio)) && + (!Number.isFinite(Number(player.kills)) || !Number.isFinite(Number(player.deaths))) + ) { + return "No disponible"; + } + return formatDecimal(getKdRatioValue(player), 2); +} + +function getKdRatioValue(player) { if (Number.isFinite(Number(player.kd_ratio))) { - return formatDecimal(player.kd_ratio, 2); + return Number(player.kd_ratio); } const kills = Number(player.kills); const deaths = Number(player.deaths); if (!Number.isFinite(kills) || !Number.isFinite(deaths)) { - return "No disponible"; + return 0; } - return deaths > 0 ? formatDecimal(kills / deaths, 2) : formatDecimal(kills, 2); + return deaths > 0 ? kills / deaths : kills; } function formatKpm(kills, durationSeconds) { + return formatDecimal(getKpmValue(kills, durationSeconds), 2); +} + +function getKpmValue(kills, durationSeconds) { const parsedKills = Number(kills); const parsedDurationSeconds = Number(durationSeconds); if ( @@ -625,9 +805,9 @@ function formatKpm(kills, durationSeconds) { !Number.isFinite(parsedDurationSeconds) || parsedDurationSeconds <= 0 ) { - return formatDecimal(0, 2); + return 0; } - return formatDecimal(parsedKills / (parsedDurationSeconds / 60), 2); + return parsedKills / (parsedDurationSeconds / 60); } function formatNamedCounts(items) { @@ -656,6 +836,11 @@ function formatNumber(value) { return new Intl.NumberFormat("es-ES").format(parsedValue); } +function toSortableNumber(value) { + const parsedValue = Number(value); + return Number.isFinite(parsedValue) ? parsedValue : 0; +} + function formatSignedNumber(value) { const parsedValue = Number(value); if (!Number.isFinite(parsedValue) || parsedValue === 0) { diff --git a/frontend/historico-partida.html b/frontend/historico-partida.html index 903c829..b2d3e5f 100644 --- a/frontend/historico-partida.html +++ b/frontend/historico-partida.html @@ -87,6 +87,50 @@

+