diff --git a/ai/tasks/done/TASK-143-player-table-hover-stats-panel.md b/ai/tasks/done/TASK-143-player-table-hover-stats-panel.md new file mode 100644 index 0000000..c2aea64 --- /dev/null +++ b/ai/tasks/done/TASK-143-player-table-hover-stats-panel.md @@ -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. diff --git a/frontend/assets/css/historico-scoreboard-detail.css b/frontend/assets/css/historico-scoreboard-detail.css index 273edb2..f1148ce 100644 --- a/frontend/assets/css/historico-scoreboard-detail.css +++ b/frontend/assets/css/historico-scoreboard-detail.css @@ -34,6 +34,244 @@ 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 { white-space: nowrap; } @@ -71,6 +309,21 @@ 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 { display: grid; gap: 18px; diff --git a/frontend/assets/js/historico-partida.js b/frontend/assets/js/historico-partida.js index fc479e2..5f67703 100644 --- a/frontend/assets/js/historico-partida.js +++ b/frontend/assets/js/historico-partida.js @@ -213,15 +213,39 @@ function renderPlayerSection(item, nodes) { nodes.playersNote.textContent = `${formatNumber(players.length)} jugadores con estadisticas locales.`; 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; } -function renderPlayerRow(player) { +function renderPlayerRows(player, item, index) { 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 ` - - ${escapeHtml(player.player_name || player.name || "Jugador no identificado")} + + + + ${escapeHtml(team.label)} @@ -231,13 +255,194 @@ function renderPlayerRow(player) { ${escapeHtml(formatOptionalNumber(player.deaths))} ${escapeHtml(formatOptionalNumber(player.teamkills))} ${escapeHtml(formatKdRatio(player))} - ${escapeHtml(formatNamedCounts(player.top_weapons))} - ${escapeHtml(formatNamedCounts(player.most_killed))} - ${escapeHtml(formatNamedCounts(player.death_by))} + ${escapeHtml(kpm)} + + + + ${renderPlayerStatsPanel(player, item, { team, playerName, kpm })} + `; } +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 ` +
+
+
+

${escapeHtml(context.team.label)}

+

${escapeHtml(context.playerName)}

+
+
+ ${renderPlayerStatChip("K", formatOptionalNumber(player.kills))} + ${renderPlayerStatChip("D", formatOptionalNumber(player.deaths))} + ${renderPlayerStatChip("TK", formatOptionalNumber(player.teamkills))} + ${renderPlayerStatChip("K/D", formatKdRatio(player))} + ${renderPlayerStatChip("KPM", context.kpm)} +
+
+ ${ + hasExpandedStats + ? ` +
+ ${renderNamedCountSection("Armas", player.top_weapons)} + ${renderNamedCountSection("Mas abatido", player.most_killed)} + ${renderNamedCountSection("Muere por", player.death_by)} + ${renderDirectMatchupsSection(matchups)} +
+ ` + : `

Sin estadisticas ampliadas disponibles.

` + } +
+ `; +} + +function renderPlayerStatChip(label, value) { + return ` +
+ ${escapeHtml(label)} + ${escapeHtml(value)} +
+ `; +} + +function renderNamedCountSection(title, items) { + if (!hasNamedCounts(items)) { + return ` +
+
${escapeHtml(title)}
+

No disponible

+
+ `; + } + return ` +
+
${escapeHtml(title)}
+
    + ${items + .map((stat) => { + const name = stat.name || stat.label || "Sin nombre"; + const count = stat.count ?? stat.total ?? 0; + return `
  1. ${escapeHtml(name)}${escapeHtml(formatNumber(count))}
  2. `; + }) + .join("")} +
+
+ `; +} + +function renderDirectMatchupsSection(matchups) { + if (!matchups.length) { + return ` +
+
Duelo directo
+

No disponible

+
+ `; + } + return ` +
+
Duelo directo
+
+
+ Rival + Abatidos + Muertes + Balance +
+ ${matchups + .map( + (matchup) => ` +
+ ${escapeHtml(matchup.name)} + ${escapeHtml(formatNumber(matchup.kills))} + ${escapeHtml(formatNumber(matchup.deaths))} + ${escapeHtml(formatSignedNumber(matchup.balance))} +
+ `, + ) + .join("")} +
+
+ `; +} + +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) { const matchUrl = normalizeSafePublicScoreboardMatchUrl(item.match_url); if (!matchUrl) { @@ -411,6 +616,19 @@ function formatKdRatio(player) { 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) { if (!Array.isArray(items) || items.length === 0) { return "No disponible"; @@ -425,6 +643,10 @@ function formatNamedCounts(items) { .join(" / "); } +function hasNamedCounts(items) { + return Array.isArray(items) && items.length > 0; +} + function formatNumber(value) { const parsedValue = Number(value); if (!Number.isFinite(parsedValue)) { @@ -433,6 +655,14 @@ function formatNumber(value) { 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) { const parsedValue = Number(value); if (!Number.isFinite(parsedValue)) { diff --git a/frontend/historico-partida.html b/frontend/historico-partida.html index 82bbb65..903c829 100644 --- a/frontend/historico-partida.html +++ b/frontend/historico-partida.html @@ -97,9 +97,7 @@ D TK K/D - Armas - Mas abatido - Muere por + KPM