feat: add player table sorting and filters

This commit is contained in:
devRaGonSa
2026-05-21 10:01:40 +02:00
parent 8cff65dda6
commit 6170a7af91
4 changed files with 418 additions and 14 deletions

View File

@@ -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.

View File

@@ -38,10 +38,66 @@
min-width: 760px; 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 { .historical-player-row {
outline: 0; 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:hover td,
.historical-player-row:focus-within td, .historical-player-row:focus-within td,
.historical-player-row.is-expanded td { .historical-player-row.is-expanded td {
@@ -302,6 +358,14 @@
} }
@media (max-width: 760px) { @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__header,
.historical-player-stats-panel__grid { .historical-player-stats-panel__grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;

View File

@@ -14,6 +14,11 @@ document.addEventListener("DOMContentLoaded", () => {
playersSection: document.getElementById("match-detail-players-section"), playersSection: document.getElementById("match-detail-players-section"),
playersNote: document.getElementById("match-detail-players-note"), playersNote: document.getElementById("match-detail-players-note"),
playersState: document.getElementById("match-detail-players-state"), 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"), playersTableShell: document.getElementById("match-detail-players-table-shell"),
playersBody: document.getElementById("match-detail-players-body"), playersBody: document.getElementById("match-detail-players-body"),
timelineSection: document.getElementById("match-detail-timeline-section"), timelineSection: document.getElementById("match-detail-timeline-section"),
@@ -206,29 +211,159 @@ function renderPlayerSection(item, nodes) {
nodes.playersState, nodes.playersState,
"No hay filas de jugador registradas para esta partida.", "No hay filas de jugador registradas para esta partida.",
); );
nodes.playerControls.hidden = true;
nodes.playersTableShell.hidden = true; nodes.playersTableShell.hidden = true;
nodes.playersBody.innerHTML = ""; nodes.playersBody.innerHTML = "";
return; return;
} }
nodes.playersNote.textContent = `${formatNumber(players.length)} jugadores con estadisticas locales.`; const state = {
nodes.playersState.hidden = true; search: "",
nodes.playersBody.innerHTML = players team: "all",
.map((player, index) => renderPlayerRows(player, item, index)) sort: "kills",
.join(""); direction: "desc",
bindPlayerDetailRows(nodes.playersBody); 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; 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 team = getTeamSideDisplay(player.team || player.team_side);
const rowId = `match-player-row-${index}`; const rowId = `match-player-row-${index}`;
const panelId = `match-player-panel-${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); const kpm = formatKpm(player.kills, item.duration_seconds);
return ` return `
<tr <tr
class="historical-player-row historical-player-row--${team.key}" class="historical-player-row historical-player-row--${team.key} ${inactive ? "is-inactive" : ""}"
id="${escapeHtml(rowId)}" id="${escapeHtml(rowId)}"
> >
<td> <td>
@@ -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) { function renderPlayerStatsPanel(player, item, context) {
const matchups = buildPlayerDirectMatchups(player); const matchups = buildPlayerDirectMatchups(player);
const hasExpandedStats = const hasExpandedStats =
@@ -606,18 +772,32 @@ function formatOptionalNumber(value) {
} }
function formatKdRatio(player) { 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))) { if (Number.isFinite(Number(player.kd_ratio))) {
return formatDecimal(player.kd_ratio, 2); return Number(player.kd_ratio);
} }
const kills = Number(player.kills); const kills = Number(player.kills);
const deaths = Number(player.deaths); const deaths = Number(player.deaths);
if (!Number.isFinite(kills) || !Number.isFinite(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) { function formatKpm(kills, durationSeconds) {
return formatDecimal(getKpmValue(kills, durationSeconds), 2);
}
function getKpmValue(kills, durationSeconds) {
const parsedKills = Number(kills); const parsedKills = Number(kills);
const parsedDurationSeconds = Number(durationSeconds); const parsedDurationSeconds = Number(durationSeconds);
if ( if (
@@ -625,9 +805,9 @@ function formatKpm(kills, durationSeconds) {
!Number.isFinite(parsedDurationSeconds) || !Number.isFinite(parsedDurationSeconds) ||
parsedDurationSeconds <= 0 parsedDurationSeconds <= 0
) { ) {
return formatDecimal(0, 2); return 0;
} }
return formatDecimal(parsedKills / (parsedDurationSeconds / 60), 2); return parsedKills / (parsedDurationSeconds / 60);
} }
function formatNamedCounts(items) { function formatNamedCounts(items) {
@@ -656,6 +836,11 @@ function formatNumber(value) {
return new Intl.NumberFormat("es-ES").format(parsedValue); return new Intl.NumberFormat("es-ES").format(parsedValue);
} }
function toSortableNumber(value) {
const parsedValue = Number(value);
return Number.isFinite(parsedValue) ? parsedValue : 0;
}
function formatSignedNumber(value) { function formatSignedNumber(value) {
const parsedValue = Number(value); const parsedValue = Number(value);
if (!Number.isFinite(parsedValue) || parsedValue === 0) { if (!Number.isFinite(parsedValue) || parsedValue === 0) {

View File

@@ -87,6 +87,50 @@
</p> </p>
</div> </div>
<p class="historical-state" id="match-detail-players-state" hidden></p> <p class="historical-state" id="match-detail-players-state" hidden></p>
<form
class="historical-player-controls"
id="match-detail-player-controls"
aria-label="Controles de jugadores"
hidden
>
<label class="historical-player-control historical-player-control--search">
<span>Buscar jugador</span>
<input
id="match-detail-player-search"
type="search"
placeholder="Nombre del jugador"
autocomplete="off"
/>
</label>
<label class="historical-player-control">
<span>Equipo</span>
<select id="match-detail-player-team-filter">
<option value="all">Todos</option>
<option value="allies">Aliados</option>
<option value="axis">Eje</option>
<option value="unknown">Sin equipo / No disponible</option>
</select>
</label>
<label class="historical-player-control">
<span>Ordenar por</span>
<select id="match-detail-player-sort">
<option value="name">Jugador</option>
<option value="team">Equipo</option>
<option value="kills" selected>K</option>
<option value="deaths">D</option>
<option value="teamkills">TK</option>
<option value="kd">K/D</option>
<option value="kpm">KPM</option>
</select>
</label>
<label class="historical-player-control">
<span>Direccion</span>
<select id="match-detail-player-sort-direction">
<option value="desc" selected>Descendente</option>
<option value="asc">Ascendente</option>
</select>
</label>
</form>
<div class="historical-table-shell" id="match-detail-players-table-shell" hidden> <div class="historical-table-shell" id="match-detail-players-table-shell" hidden>
<table class="historical-table historical-table--players"> <table class="historical-table historical-table--players">
<thead> <thead>