fix: distinguish player teams and correct recent player counts
This commit is contained in:
@@ -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.
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
*,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user