feat: add player hover stats panel

This commit is contained in:
devRaGonSa
2026-05-20 21:18:44 +02:00
parent d3f08be880
commit b534f0c7c3
4 changed files with 571 additions and 10 deletions

View File

@@ -0,0 +1,80 @@
---
id: TASK-143
title: Player table hover stats panel
status: done
type: frontend
team: Frontend Senior
supporting_teams: [Experto en interfaz]
roadmap_item: foundation
priority: high
---
# TASK-143 - Player table hover stats panel
## Goal
Make the internal historical match detail player table more compact by moving expanded per-player statistics into an accessible hover/focus/click details panel.
## Context
The detail page player table currently includes detailed columns for weapons, most killed and death by. The desired scoreboard-like UX keeps the table focused on player/team/core combat metrics and reveals expanded statistics per row.
## Steps
1. Inspect the current match detail renderer and scoreboard detail CSS.
2. Reduce the visible table columns to Jugador, Equipo, K, D, TK, K/D and KPM.
3. Add accessible row hover/focus/click expanded panels with weapons, most killed, death by and direct matchups.
4. Preserve team styling, scoreboard link behavior and forbidden hidden sections.
5. Validate syntax, frontend build/container and rendered behavior.
## 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`
- `frontend/assets/css/historico.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-143-player-table-hover-stats-panel.md`
## Constraints
- Do not modify backend unless absolutely necessary.
- Do not modify recent match card layout.
- Do not reintroduce timeline/events/confidence/source/base or Elo/MVP blocks.
- Preserve Aliados/Eje/No disponible visual distinction.
## 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`
- Browser verification on `historico-partida.html`
- Browser smoke verification on `historico.html`
## Outcome
Implemented. The match detail player table now shows only Jugador, Equipo, K, D, TK, K/D and KPM. Expanded player statistics are rendered in per-player detail panels that open on row hover, keyboard focus/Enter and touch/click via an accessible info control. The panel includes the player/team summary, weapons, most killed, death by and direct matchup balance derived from most_killed and death_by.
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`
- Browser verification on `historico-partida.html` for `comunidad-hispana-02:1779178461:1779183861:carentanwarfare`
- Browser smoke verification on `historico.html?nocache=player-table-hover`
Notes:
- Browser plugin runtime tools were not exposed by tool discovery in this session, so rendered validation used local Chrome/Selenium fallback.

View File

@@ -34,6 +34,244 @@
background: linear-gradient(90deg, rgba(159, 168, 141, 0.08), transparent 42%); background: linear-gradient(90deg, rgba(159, 168, 141, 0.08), transparent 42%);
} }
.historical-table--players {
min-width: 760px;
}
.historical-player-row {
cursor: pointer;
outline: 0;
}
.historical-player-row:hover td,
.historical-player-row:focus td,
.historical-player-row:focus-within td,
.historical-player-row.is-expanded td {
background: rgba(210, 182, 118, 0.06);
}
.historical-player-row:focus-visible td,
.historical-player-row__details-button:focus-visible {
outline: 2px solid rgba(210, 182, 118, 0.78);
outline-offset: -2px;
}
.historical-player-row__details-button {
display: flex;
width: 100%;
min-width: 220px;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0;
border: 0;
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
.historical-player-row__details-button span:first-child {
overflow: hidden;
min-width: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
.historical-player-row__details-button span:last-child {
flex: 0 0 auto;
display: inline-flex;
width: 22px;
height: 22px;
align-items: center;
justify-content: center;
border: 1px solid rgba(210, 182, 118, 0.28);
border-radius: 999px;
color: var(--accent-strong);
font-size: 0.72rem;
font-weight: 900;
line-height: 1;
text-transform: uppercase;
}
.historical-player-detail-row {
display: none;
}
.historical-player-row:hover + .historical-player-detail-row,
.historical-player-row:focus + .historical-player-detail-row,
.historical-player-row:focus-within + .historical-player-detail-row,
.historical-player-detail-row:hover,
.historical-player-detail-row:focus-within,
.historical-player-detail-row.is-open {
display: table-row;
}
.historical-player-detail-row td {
padding: 0 12px 16px;
background: rgba(7, 9, 7, 0.76);
border-bottom-color: rgba(210, 182, 118, 0.16);
}
.historical-player-stats-panel {
display: grid;
gap: 16px;
padding: 16px;
border: 1px solid rgba(210, 182, 118, 0.18);
border-radius: 16px;
background:
linear-gradient(180deg, rgba(19, 24, 17, 0.96), rgba(10, 13, 10, 0.98)),
rgba(10, 13, 10, 0.96);
box-shadow: 0 18px 34px rgba(0, 0, 0, 0.28);
}
.historical-player-stats-panel__header {
display: grid;
grid-template-columns: minmax(180px, 1fr) minmax(0, 2fr);
gap: 16px;
align-items: start;
}
.historical-player-stats-panel__header p,
.historical-player-stats-panel__section p,
.historical-player-stats-panel__empty {
margin: 0;
color: var(--muted);
}
.historical-player-stats-panel__header p {
margin-bottom: 5px;
font-size: 0.72rem;
font-weight: 900;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.historical-player-stats-panel__header h4,
.historical-player-stats-panel__section h5 {
margin: 0;
color: var(--text);
}
.historical-player-stats-panel__header h4 {
font-size: 1.08rem;
}
.historical-player-stats-panel__summary {
display: grid;
grid-template-columns: repeat(5, minmax(70px, 1fr));
gap: 8px;
}
.historical-player-stats-panel__summary article {
padding: 9px 10px;
border: 1px solid rgba(159, 168, 141, 0.14);
border-radius: 10px;
background: rgba(13, 17, 12, 0.68);
}
.historical-player-stats-panel__summary span {
display: block;
margin-bottom: 4px;
color: var(--muted);
font-size: 0.64rem;
font-weight: 900;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.historical-player-stats-panel__summary strong {
color: var(--text);
}
.historical-player-stats-panel__grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.historical-player-stats-panel__section {
display: grid;
gap: 10px;
align-content: start;
padding: 13px;
border: 1px solid rgba(159, 168, 141, 0.14);
border-radius: 12px;
background: rgba(13, 17, 12, 0.52);
}
.historical-player-stats-panel__section--wide {
grid-column: 1 / -1;
}
.historical-player-stats-panel__section h5 {
font-size: 0.78rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.historical-player-stats-panel__section ol {
display: grid;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
.historical-player-stats-panel__section li {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
align-items: baseline;
}
.historical-player-stats-panel__section li span {
overflow: hidden;
min-width: 0;
color: var(--text-soft);
text-overflow: ellipsis;
white-space: nowrap;
}
.historical-player-stats-panel__section li strong {
color: var(--accent-strong);
}
.historical-player-matchups {
display: grid;
gap: 0;
overflow-x: auto;
}
.historical-player-matchups [role="row"] {
display: grid;
grid-template-columns: minmax(180px, 1fr) repeat(3, minmax(72px, auto));
gap: 10px;
padding: 8px 0;
border-bottom: 1px solid rgba(159, 168, 141, 0.1);
}
.historical-player-matchups [role="row"]:last-child {
border-bottom: 0;
}
.historical-player-matchups [role="columnheader"] {
color: var(--muted);
font-size: 0.66rem;
font-weight: 900;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.historical-player-matchups [role="cell"] {
color: var(--text-soft);
}
.historical-player-matchups strong[role="cell"] {
color: var(--text);
}
.historical-player-team-cell { .historical-player-team-cell {
white-space: nowrap; white-space: nowrap;
} }
@@ -71,6 +309,21 @@
color: var(--muted); color: var(--muted);
} }
@media (max-width: 760px) {
.historical-player-stats-panel__header,
.historical-player-stats-panel__grid {
grid-template-columns: 1fr;
}
.historical-player-stats-panel__summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.historical-player-matchups [role="row"] {
min-width: 520px;
}
}
.historical-scoreboard-layout { .historical-scoreboard-layout {
display: grid; display: grid;
gap: 18px; gap: 18px;

View File

@@ -213,15 +213,39 @@ function renderPlayerSection(item, nodes) {
nodes.playersNote.textContent = `${formatNumber(players.length)} jugadores con estadisticas locales.`; nodes.playersNote.textContent = `${formatNumber(players.length)} jugadores con estadisticas locales.`;
nodes.playersState.hidden = true; nodes.playersState.hidden = true;
nodes.playersBody.innerHTML = players.map((player) => renderPlayerRow(player)).join(""); nodes.playersBody.innerHTML = players
.map((player, index) => renderPlayerRows(player, item, index))
.join("");
bindPlayerDetailRows(nodes.playersBody);
nodes.playersTableShell.hidden = false; nodes.playersTableShell.hidden = false;
} }
function renderPlayerRow(player) { function renderPlayerRows(player, item, index) {
const team = getTeamSideDisplay(player.team || player.team_side); 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 kpm = formatKpm(player.kills, item.duration_seconds);
return ` return `
<tr class="historical-player-row historical-player-row--${team.key}"> <tr
<td>${escapeHtml(player.player_name || player.name || "Jugador no identificado")}</td> class="historical-player-row historical-player-row--${team.key}"
id="${escapeHtml(rowId)}"
tabindex="0"
aria-controls="${escapeHtml(panelId)}"
aria-expanded="false"
>
<td>
<button
class="historical-player-row__details-button"
type="button"
aria-controls="${escapeHtml(panelId)}"
aria-expanded="false"
aria-label="Ver estadisticas ampliadas de ${escapeHtml(playerName)}"
>
<span>${escapeHtml(playerName)}</span>
<span aria-hidden="true">i</span>
</button>
</td>
<td class="historical-player-team-cell"> <td class="historical-player-team-cell">
<span class="historical-player-team-badge historical-player-team-badge--${team.key}"> <span class="historical-player-team-badge historical-player-team-badge--${team.key}">
${escapeHtml(team.label)} ${escapeHtml(team.label)}
@@ -231,13 +255,194 @@ function renderPlayerRow(player) {
<td>${escapeHtml(formatOptionalNumber(player.deaths))}</td> <td>${escapeHtml(formatOptionalNumber(player.deaths))}</td>
<td>${escapeHtml(formatOptionalNumber(player.teamkills))}</td> <td>${escapeHtml(formatOptionalNumber(player.teamkills))}</td>
<td>${escapeHtml(formatKdRatio(player))}</td> <td>${escapeHtml(formatKdRatio(player))}</td>
<td>${escapeHtml(formatNamedCounts(player.top_weapons))}</td> <td>${escapeHtml(kpm)}</td>
<td>${escapeHtml(formatNamedCounts(player.most_killed))}</td> </tr>
<td>${escapeHtml(formatNamedCounts(player.death_by))}</td> <tr
class="historical-player-detail-row"
id="${escapeHtml(panelId)}"
aria-labelledby="${escapeHtml(rowId)}"
>
<td colspan="7">
${renderPlayerStatsPanel(player, item, { team, playerName, kpm })}
</td>
</tr> </tr>
`; `;
} }
function bindPlayerDetailRows(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;
}
const setExpanded = (expanded) => {
row.classList.toggle("is-expanded", expanded);
detailRow.classList.toggle("is-open", expanded);
row.setAttribute("aria-expanded", String(expanded));
button.setAttribute("aria-expanded", String(expanded));
};
const toggleExpanded = () => setExpanded(!detailRow.classList.contains("is-open"));
row.addEventListener("click", (event) => {
if (event.target.closest("a")) {
return;
}
toggleExpanded();
});
row.addEventListener("keydown", (event) => {
if (event.key !== "Enter" && event.key !== " ") {
return;
}
event.preventDefault();
toggleExpanded();
});
});
}
function renderPlayerStatsPanel(player, item, context) {
const matchups = buildPlayerDirectMatchups(player);
const hasExpandedStats =
hasNamedCounts(player.top_weapons) ||
hasNamedCounts(player.most_killed) ||
hasNamedCounts(player.death_by) ||
matchups.length > 0;
return `
<section class="historical-player-stats-panel" aria-label="Estadisticas ampliadas de ${escapeHtml(context.playerName)}">
<div class="historical-player-stats-panel__header">
<div>
<p>${escapeHtml(context.team.label)}</p>
<h4>${escapeHtml(context.playerName)}</h4>
</div>
<div class="historical-player-stats-panel__summary">
${renderPlayerStatChip("K", formatOptionalNumber(player.kills))}
${renderPlayerStatChip("D", formatOptionalNumber(player.deaths))}
${renderPlayerStatChip("TK", formatOptionalNumber(player.teamkills))}
${renderPlayerStatChip("K/D", formatKdRatio(player))}
${renderPlayerStatChip("KPM", context.kpm)}
</div>
</div>
${
hasExpandedStats
? `
<div class="historical-player-stats-panel__grid">
${renderNamedCountSection("Armas", player.top_weapons)}
${renderNamedCountSection("Mas abatido", player.most_killed)}
${renderNamedCountSection("Muere por", player.death_by)}
${renderDirectMatchupsSection(matchups)}
</div>
`
: `<p class="historical-player-stats-panel__empty">Sin estadisticas ampliadas disponibles.</p>`
}
</section>
`;
}
function renderPlayerStatChip(label, value) {
return `
<article>
<span>${escapeHtml(label)}</span>
<strong>${escapeHtml(value)}</strong>
</article>
`;
}
function renderNamedCountSection(title, items) {
if (!hasNamedCounts(items)) {
return `
<article class="historical-player-stats-panel__section">
<h5>${escapeHtml(title)}</h5>
<p>No disponible</p>
</article>
`;
}
return `
<article class="historical-player-stats-panel__section">
<h5>${escapeHtml(title)}</h5>
<ol>
${items
.map((stat) => {
const name = stat.name || stat.label || "Sin nombre";
const count = stat.count ?? stat.total ?? 0;
return `<li><span>${escapeHtml(name)}</span><strong>${escapeHtml(formatNumber(count))}</strong></li>`;
})
.join("")}
</ol>
</article>
`;
}
function renderDirectMatchupsSection(matchups) {
if (!matchups.length) {
return `
<article class="historical-player-stats-panel__section historical-player-stats-panel__section--wide">
<h5>Duelo directo</h5>
<p>No disponible</p>
</article>
`;
}
return `
<article class="historical-player-stats-panel__section historical-player-stats-panel__section--wide">
<h5>Duelo directo</h5>
<div class="historical-player-matchups" role="table" aria-label="Duelos directos">
<div role="row">
<span role="columnheader">Rival</span>
<span role="columnheader">Abatidos</span>
<span role="columnheader">Muertes</span>
<span role="columnheader">Balance</span>
</div>
${matchups
.map(
(matchup) => `
<div role="row">
<span role="cell">${escapeHtml(matchup.name)}</span>
<strong role="cell">${escapeHtml(formatNumber(matchup.kills))}</strong>
<strong role="cell">${escapeHtml(formatNumber(matchup.deaths))}</strong>
<strong role="cell">${escapeHtml(formatSignedNumber(matchup.balance))}</strong>
</div>
`,
)
.join("")}
</div>
</article>
`;
}
function buildPlayerDirectMatchups(player) {
const byName = new Map();
const addStats = (items, key) => {
if (!Array.isArray(items)) {
return;
}
items.forEach((item) => {
const name = item.name || item.label;
if (!name) {
return;
}
const normalizedName = String(name);
const current = byName.get(normalizedName) || {
name: normalizedName,
kills: 0,
deaths: 0,
};
current[key] += Number(item.count ?? item.total ?? 0) || 0;
byName.set(normalizedName, current);
});
};
addStats(player.most_killed, "kills");
addStats(player.death_by, "deaths");
return [...byName.values()]
.map((matchup) => ({
...matchup,
balance: matchup.kills - matchup.deaths,
involvement: matchup.kills + matchup.deaths,
}))
.sort((a, b) => b.involvement - a.involvement || a.name.localeCompare(b.name, "es"))
.slice(0, 8);
}
function renderActions(item, actionsNode) { function renderActions(item, actionsNode) {
const matchUrl = normalizeSafePublicScoreboardMatchUrl(item.match_url); const matchUrl = normalizeSafePublicScoreboardMatchUrl(item.match_url);
if (!matchUrl) { if (!matchUrl) {
@@ -411,6 +616,19 @@ function formatKdRatio(player) {
return deaths > 0 ? formatDecimal(kills / deaths, 2) : formatDecimal(kills, 2); return deaths > 0 ? formatDecimal(kills / deaths, 2) : formatDecimal(kills, 2);
} }
function formatKpm(kills, durationSeconds) {
const parsedKills = Number(kills);
const parsedDurationSeconds = Number(durationSeconds);
if (
!Number.isFinite(parsedKills) ||
!Number.isFinite(parsedDurationSeconds) ||
parsedDurationSeconds <= 0
) {
return formatDecimal(0, 2);
}
return formatDecimal(parsedKills / (parsedDurationSeconds / 60), 2);
}
function formatNamedCounts(items) { function formatNamedCounts(items) {
if (!Array.isArray(items) || items.length === 0) { if (!Array.isArray(items) || items.length === 0) {
return "No disponible"; return "No disponible";
@@ -425,6 +643,10 @@ function formatNamedCounts(items) {
.join(" / "); .join(" / ");
} }
function hasNamedCounts(items) {
return Array.isArray(items) && items.length > 0;
}
function formatNumber(value) { function formatNumber(value) {
const parsedValue = Number(value); const parsedValue = Number(value);
if (!Number.isFinite(parsedValue)) { if (!Number.isFinite(parsedValue)) {
@@ -433,6 +655,14 @@ function formatNumber(value) {
return new Intl.NumberFormat("es-ES").format(parsedValue); return new Intl.NumberFormat("es-ES").format(parsedValue);
} }
function formatSignedNumber(value) {
const parsedValue = Number(value);
if (!Number.isFinite(parsedValue) || parsedValue === 0) {
return "0";
}
return `${parsedValue > 0 ? "+" : ""}${formatNumber(parsedValue)}`;
}
function formatDecimal(value, fractionDigits = 1) { function formatDecimal(value, fractionDigits = 1) {
const parsedValue = Number(value); const parsedValue = Number(value);
if (!Number.isFinite(parsedValue)) { if (!Number.isFinite(parsedValue)) {

View File

@@ -97,9 +97,7 @@
<th>D</th> <th>D</th>
<th>TK</th> <th>TK</th>
<th>K/D</th> <th>K/D</th>
<th>Armas</th> <th>KPM</th>
<th>Mas abatido</th>
<th>Muere por</th>
</tr> </tr>
</thead> </thead>
<tbody id="match-detail-players-body"></tbody> <tbody id="match-detail-players-body"></tbody>