fix: distinguish player teams and correct recent player counts

This commit is contained in:
devRaGonSa
2026-05-20 20:50:07 +02:00
parent 8d97dde326
commit 73498a80df
5 changed files with 215 additions and 15 deletions

View File

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

View File

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

View File

@@ -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],
*,

View File

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

View File

@@ -218,10 +218,15 @@ function renderPlayerSection(item, nodes) {
}
function renderPlayerRow(player) {
const team = getTeamSideDisplay(player.team || player.team_side);
return `
<tr>
<tr class="historical-player-row historical-player-row--${team.key}">
<td>${escapeHtml(player.player_name || player.name || "Jugador no identificado")}</td>
<td>${escapeHtml(formatTeamSide(player.team || player.team_side))}</td>
<td class="historical-player-team-cell">
<span class="historical-player-team-badge historical-player-team-badge--${team.key}">
${escapeHtml(team.label)}
</span>
</td>
<td>${escapeHtml(formatOptionalNumber(player.kills))}</td>
<td>${escapeHtml(formatOptionalNumber(player.deaths))}</td>
<td>${escapeHtml(formatOptionalNumber(player.teamkills))}</td>
@@ -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) {