diff --git a/ai/tasks/done/TASK-142-player-team-visuals-and-recent-counts.md b/ai/tasks/done/TASK-142-player-team-visuals-and-recent-counts.md new file mode 100644 index 0000000..11f3505 --- /dev/null +++ b/ai/tasks/done/TASK-142-player-team-visuals-and-recent-counts.md @@ -0,0 +1,99 @@ +--- +id: TASK-142 +title: Player team visuals and recent counts +status: done +type: frontend +team: Frontend Senior +supporting_teams: [Backend Senior, Experto en interfaz] +roadmap_item: foundation +priority: high +--- + +# TASK-142 - Player team visuals and recent counts + +## Goal + +Fix historical UI/data consistency by visually distinguishing player teams in internal match detail tables and exposing meaningful recent-match player counts for RCON materialized matches. + +## Context + +Recent match cards can show `Jugadores = 0` even when the corresponding internal detail page has materialized player rows. The detail player table also needs an additive visual distinction for Allies/Aliados and Axis/Eje rows while preserving all existing stats and detail-page behavior. + +## Steps + +1. Inspect the listed files first. +2. Fix the backend RCON recent-match read model to expose a non-zero materialized player count when player stats exist. +3. Add team-specific visual styling to all internal match detail player rows or team cells. +4. Validate backend, frontend syntax, RCON pipeline, integration scripts, advanced Compose services and rendered UI behavior. + +## Files to Read First + +- `ai/architecture-index.md` +- `ai/repo-context.md` +- `ai/orchestrator/backend-senior.md` +- `ai/orchestrator/frontend-senior.md` +- `backend/app/rcon_historical_read_model.py` +- `backend/app/rcon_admin_log_materialization.py` +- `backend/app/rcon_admin_log_storage.py` +- `frontend/assets/js/historico-partida.js` +- `frontend/assets/css/historico-scoreboard-detail.css` +- `frontend/assets/css/historico.css` + +## Expected Files to Modify + +- `backend/app/rcon_historical_read_model.py` +- `backend/app/rcon_admin_log_materialization.py` +- `frontend/assets/js/historico-partida.js` +- `frontend/assets/css/historico-scoreboard-detail.css` +- `ai/tasks/done/TASK-142-player-team-visuals-and-recent-counts.md` + +## Constraints + +- Do not modify public scoreboard behavior. +- Do not modify frontend recent-card layout beyond consuming corrected backend data. +- Do not reintroduce raw match id, Estado, Resultado confirmado, Fuente, RCON/debug text, timeline/events, confidence/source/base, Elo/MVP blocks or Comunidad Hispana #03. +- Do not commit runtime DB files. + +## Validation + +- `python -m compileall backend/app` +- `node --check frontend/assets/js/historico.js` +- `node --check frontend/assets/js/historico-recent-live.js` +- `node --check frontend/assets/js/historico-partida.js` +- `powershell -ExecutionPolicy Bypass -File scripts/run-rcon-data-pipeline-tests.ps1` +- `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1` +- `docker compose --profile advanced up -d --build backend frontend historical-runner rcon-historical-worker` +- `docker compose --profile advanced ps` +- `Invoke-WebRequest "http://localhost:8000/health" | Select-Object -ExpandProperty Content` +- `Invoke-WebRequest "http://localhost:8000/api/historical/recent-matches?server=all-servers&limit=20" | Select-Object -ExpandProperty Content` +- Browser verification on `http://localhost:8080/historico.html?nocache=player-counts` +- Browser verification on an internal match detail page with materialized players + +## Outcome + +Implemented. Materialized RCON recent-match rows now include player-stat counts and the RCON historical read model exposes those counts as `player_count` for recent cards and detail payloads. The internal match detail player table now renders localized team badges and team-specific row accents for Aliados, Eje and No disponible while preserving the existing stats columns and detail-page scoreboard link. + +Validation passed: + +- `python -m compileall backend/app` +- `node --check frontend/assets/js/historico.js` +- `node --check frontend/assets/js/historico-recent-live.js` +- `node --check frontend/assets/js/historico-partida.js` +- `powershell -ExecutionPolicy Bypass -File scripts/run-rcon-data-pipeline-tests.ps1` +- `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1` +- `docker compose --profile advanced up -d --build backend frontend historical-runner rcon-historical-worker` +- `docker compose --profile advanced ps` +- `Invoke-WebRequest "http://localhost:8000/health" | Select-Object -ExpandProperty Content` +- `Invoke-WebRequest "http://localhost:8000/api/historical/recent-matches?server=all-servers&limit=20" | Select-Object -ExpandProperty Content` +- Browser verification on `http://localhost:8080/historico.html?nocache=player-counts` +- Browser verification on `historico-partida.html` for `comunidad-hispana-02:1779178461:1779183861:carentanwarfare` + +Notes: + +- The RCON pipeline test reports existing SQLite `ResourceWarning` messages from its test harness, but both unittest suites return `OK` and the script reports validation passed. +- The browser plugin was not exposed by tool discovery in this session, so rendered UI verification used local Chrome/Selenium fallback. + +## Change Budget + +- Prefer fewer than 5 modified files. +- Prefer changes under 200 lines when feasible. diff --git a/backend/app/rcon_admin_log_materialization.py b/backend/app/rcon_admin_log_materialization.py index 691dae3..4a0d291 100644 --- a/backend/app/rcon_admin_log_materialization.py +++ b/backend/app/rcon_admin_log_materialization.py @@ -149,21 +149,35 @@ def list_materialized_rcon_matches( clauses: list[str] = [] params: list[object] = [] if target_key: - clauses.append("(target_key = ? OR external_server_id = ?)") + clauses.append("(m.target_key = ? OR m.external_server_id = ?)") params.extend([target_key, target_key]) if only_ended: - clauses.append("source_basis = ?") + clauses.append("m.source_basis = ?") params.append(MATCH_RESULT_SOURCE) where = "WHERE " + " AND ".join(clauses) if clauses else "" params.append(limit) with closing(connect_sqlite_readonly(resolved_path)) as connection: rows = connection.execute( f""" - SELECT * - FROM rcon_materialized_matches + SELECT + m.*, + ( + SELECT COUNT(*) + FROM rcon_match_player_stats AS stats + WHERE stats.target_key = m.target_key + AND stats.match_key = m.match_key + ) AS materialized_player_count, + ( + SELECT COUNT(DISTINCT TRIM(stats.player_name)) + FROM rcon_match_player_stats AS stats + WHERE stats.target_key = m.target_key + AND stats.match_key = m.match_key + AND TRIM(COALESCE(stats.player_name, '')) != '' + ) AS materialized_distinct_player_count + FROM rcon_materialized_matches AS m {where} - ORDER BY COALESCE(ended_at, started_at) DESC, - COALESCE(ended_server_time, started_server_time) DESC + ORDER BY COALESCE(m.ended_at, m.started_at) DESC, + COALESCE(m.ended_server_time, m.started_server_time) DESC LIMIT ? """, params, diff --git a/backend/app/rcon_historical_read_model.py b/backend/app/rcon_historical_read_model.py index 697014b..cf8a85f 100644 --- a/backend/app/rcon_historical_read_model.py +++ b/backend/app/rcon_historical_read_model.py @@ -208,6 +208,7 @@ def _build_materialized_recent_item(item: dict[str, object]) -> dict[str, object server_slug = item.get("external_server_id") or item.get("target_key") timestamps = _build_materialized_timestamp_payload(item) correlation_window = _build_materialized_scoreboard_correlation_window(item, timestamps) + player_count = _resolve_materialized_player_count(item) return { "server": { "slug": item.get("target_key"), @@ -232,7 +233,7 @@ def _build_materialized_recent_item(item: dict[str, object]) -> dict[str, object "winner": item.get("winner"), }, "winner": item.get("winner"), - "player_count": None, + "player_count": player_count, "peak_players": None, "sample_count": None, "duration_seconds": _calculate_match_duration_seconds(item), @@ -273,6 +274,7 @@ def _build_materialized_detail_item(materialized: dict[str, object]) -> dict[str ) for row in materialized["players"] ] + player_count = len(players) if players else recent_item.get("player_count") return { **recent_item, "match_id": match["match_key"], @@ -280,6 +282,7 @@ def _build_materialized_detail_item(materialized: dict[str, object]) -> dict[str "winner": match.get("winner"), "confidence": match.get("confidence_mode"), "source_basis": match.get("source_basis"), + "player_count": player_count, "players": players, "timeline": { "event_counts": materialized.get("timeline", []), @@ -287,6 +290,18 @@ def _build_materialized_detail_item(materialized: dict[str, object]) -> dict[str } +def _resolve_materialized_player_count(item: dict[str, object]) -> int | None: + for key in ( + "player_count", + "materialized_player_count", + "materialized_distinct_player_count", + ): + value = _coerce_optional_int(item.get(key)) + if value is not None and value > 0: + return value + return None + + def _build_player_row( row: dict[str, object], *, diff --git a/frontend/assets/css/historico-scoreboard-detail.css b/frontend/assets/css/historico-scoreboard-detail.css index 0015199..273edb2 100644 --- a/frontend/assets/css/historico-scoreboard-detail.css +++ b/frontend/assets/css/historico-scoreboard-detail.css @@ -10,6 +10,67 @@ display: none !important; } +.historical-table--players tbody tr.historical-player-row--allies td:first-child { + box-shadow: inset 4px 0 0 rgba(104, 162, 214, 0.82); +} + +.historical-table--players tbody tr.historical-player-row--axis td:first-child { + box-shadow: inset 4px 0 0 rgba(190, 82, 64, 0.82); +} + +.historical-table--players tbody tr.historical-player-row--unknown td:first-child { + box-shadow: inset 4px 0 0 rgba(159, 168, 141, 0.5); +} + +.historical-table--players tbody tr.historical-player-row--allies { + background: linear-gradient(90deg, rgba(74, 126, 178, 0.14), transparent 42%); +} + +.historical-table--players tbody tr.historical-player-row--axis { + background: linear-gradient(90deg, rgba(156, 66, 49, 0.16), transparent 42%); +} + +.historical-table--players tbody tr.historical-player-row--unknown { + background: linear-gradient(90deg, rgba(159, 168, 141, 0.08), transparent 42%); +} + +.historical-player-team-cell { + white-space: nowrap; +} + +.historical-player-team-badge { + display: inline-flex; + align-items: center; + min-width: 96px; + justify-content: center; + padding: 5px 10px; + border: 1px solid rgba(159, 168, 141, 0.24); + border-radius: 999px; + font-size: 0.72rem; + font-weight: 900; + letter-spacing: 0.08em; + line-height: 1; + text-transform: uppercase; +} + +.historical-player-team-badge--allies { + border-color: rgba(118, 175, 229, 0.46); + background: rgba(61, 109, 163, 0.2); + color: #c8e1ff; +} + +.historical-player-team-badge--axis { + border-color: rgba(213, 105, 83, 0.48); + background: rgba(129, 45, 35, 0.22); + color: #f2beb2; +} + +.historical-player-team-badge--unknown { + border-color: rgba(159, 168, 141, 0.28); + background: rgba(159, 168, 141, 0.1); + color: var(--muted); +} + .historical-scoreboard-layout { display: grid; gap: 18px; diff --git a/frontend/assets/js/historico-partida.js b/frontend/assets/js/historico-partida.js index 3d05a88..fc479e2 100644 --- a/frontend/assets/js/historico-partida.js +++ b/frontend/assets/js/historico-partida.js @@ -218,10 +218,15 @@ function renderPlayerSection(item, nodes) { } function renderPlayerRow(player) { + const team = getTeamSideDisplay(player.team || player.team_side); return ` - + ${escapeHtml(player.player_name || player.name || "Jugador no identificado")} - ${escapeHtml(formatTeamSide(player.team || player.team_side))} + + + ${escapeHtml(team.label)} + + ${escapeHtml(formatOptionalNumber(player.kills))} ${escapeHtml(formatOptionalNumber(player.deaths))} ${escapeHtml(formatOptionalNumber(player.teamkills))} @@ -338,14 +343,20 @@ function normalizeLookupText(value) { } function formatTeamSide(value) { - const normalized = String(value || "").toLowerCase(); - if (normalized === "allies" || normalized === "allied") { - return "Aliados"; + return getTeamSideDisplay(value).label; +} + +function getTeamSideDisplay(value) { + const normalized = String(value || "") + .trim() + .toLowerCase(); + if (normalized === "allies" || normalized === "allied" || normalized === "aliados") { + return { key: "allies", label: "Aliados" }; } - if (normalized === "axis") { - return "Eje"; + if (normalized === "axis" || normalized === "eje") { + return { key: "axis", label: "Eje" }; } - return value || "No disponible"; + return { key: "unknown", label: "No disponible" }; } function formatGameMode(value) {