diff --git a/ai/tasks/pending/TASK-175-add-stats-regression-validation-script.md b/ai/tasks/done/TASK-175-add-stats-regression-validation-script.md similarity index 62% rename from ai/tasks/pending/TASK-175-add-stats-regression-validation-script.md rename to ai/tasks/done/TASK-175-add-stats-regression-validation-script.md index bd98ac9..97dddcb 100644 --- a/ai/tasks/pending/TASK-175-add-stats-regression-validation-script.md +++ b/ai/tasks/done/TASK-175-add-stats-regression-validation-script.md @@ -1,7 +1,7 @@ --- id: TASK-175-add-stats-regression-validation-script title: Add Stats regression validation script -status: pending +status: done type: platform team: PM supporting_teams: @@ -67,5 +67,31 @@ The Stats section already has public-facing pages, JS assets, and backend endpoi ## Outcome -Document validated endpoints, controlled limitations, and the immediate next task recommendation. +Validated surfaces: +- `frontend/stats.html` asset wiring and required Stats UI anchors +- `GET /health` +- `GET /api/stats/players/search` +- `GET /api/stats/players/{player_id}` +- `GET /api/stats/rankings/annual` + +Implemented validation: + +- Added `scripts/run-stats-validation.ps1` to check Stats asset presence, route-contract behavior, invalid parameter handling, and controlled backend-unavailable messaging. +- Wired `scripts/run-integration-tests.ps1` to execute the Stats validation and fail on non-zero child process exit codes. + +Controlled limitations observed during validation: + +- The local live backend was not running during task validation, so live HTTP checks reported the expected offline guidance instead of ambiguous failure. +- Current normalized all-server scope is returned as `all-servers`. +- Annual ranking `data.limit` currently reflects the effective stored snapshot size when a ready snapshot contains fewer rows than the requested limit; the validator accepts that current behavior without changing runtime logic. + +Validation run: + +- `node --check frontend/assets/js/stats.js` +- `powershell -ExecutionPolicy Bypass -File scripts/run-stats-validation.ps1` +- `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1` + +Immediate follow-up recommendation: + +- Continue with `TASK-176-add-stats-player-comparison-cards`, using the new Stats validation script as the regression guard for the existing Stats surface. diff --git a/ai/tasks/pending/TASK-176-add-stats-player-comparison-cards.md b/ai/tasks/done/TASK-176-add-stats-player-comparison-cards.md similarity index 52% rename from ai/tasks/pending/TASK-176-add-stats-player-comparison-cards.md rename to ai/tasks/done/TASK-176-add-stats-player-comparison-cards.md index d297b64..a333349 100644 --- a/ai/tasks/pending/TASK-176-add-stats-player-comparison-cards.md +++ b/ai/tasks/done/TASK-176-add-stats-player-comparison-cards.md @@ -1,7 +1,7 @@ --- id: TASK-176-add-stats-player-comparison-cards title: Add stats player comparison cards -status: pending +status: done type: frontend team: Frontend Senior supporting_teams: @@ -71,5 +71,54 @@ The Stats page already exposes player lookup and detail payloads. This task shou ## Outcome -Document which comparison cards were added, payload fields consumed, behavior with no data, and any remaining follow-up improvements. +Implemented comparison cards: +- Added a dedicated comparison-card grid inside the selected-player profile panel. +- Added weekly and monthly window cards using only existing profile payload fields. +- Added a comparison card that contrasts weekly vs monthly KPM, K/D, kills delta, and matches delta. + +Payload fields consumed: + +- `player_id` +- `player_name` +- `matches_considered` +- `kills` +- `deaths` +- `teamkills` +- `kd_ratio` +- `kills_per_match` +- `deaths_per_match` +- `weekly_ranking` +- `monthly_ranking` +- `window_kind` +- `window_start` +- `window_end` + +Behavior with no data or limited ranking: + +- If both weekly and monthly windows have zero matches, the existing warning state remains and the comparison cards show explicit "Sin actividad" messaging. +- If a ranking block is absent, the cards show `Ranking ausente`. +- If a ranking exists but `ranking_position` is empty and the active window is a fallback `previous-*` window, the cards show `Profundidad insuficiente`. +- If a ranking exists but the player is not positioned in the visible ranking, the cards show `Fuera del ranking visible`. + +Implementation notes: + +- The frontend now fetches the existing player profile endpoint twice in parallel, once for `weekly` and once for `monthly`, without adding endpoints or changing backend contracts. +- The tactical visual style stays within the current Stats surface and reuses the existing palette and panel language. + +Validation run: + +- `node --check frontend/assets/js/stats.js` +- `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1` +- Local static server: + - `http://127.0.0.1:8091/stats.html` -> 200 + - `http://127.0.0.1:8091/assets/js/stats.js` -> 200 + +Known limitations: + +- Visual validation against a live local backend was not possible because `http://127.0.0.1:8000` was unavailable during this task. +- The Browser plugin surface was not available as `iab` in this session, so browser-level visual inspection could not be completed through the in-app browser. + +Immediate follow-up improvements: + +- Continue with `TASK-177-harden-annual-ranking-snapshot-operations` so the annual ranking endpoint behavior around effective limits and missing years is better defined and easier for the Stats UI to explain. diff --git a/ai/tasks/pending/TASK-177-harden-annual-ranking-snapshot-operations.md b/ai/tasks/done/TASK-177-harden-annual-ranking-snapshot-operations.md similarity index 57% rename from ai/tasks/pending/TASK-177-harden-annual-ranking-snapshot-operations.md rename to ai/tasks/done/TASK-177-harden-annual-ranking-snapshot-operations.md index 4dcc870..0dbfbf6 100644 --- a/ai/tasks/pending/TASK-177-harden-annual-ranking-snapshot-operations.md +++ b/ai/tasks/done/TASK-177-harden-annual-ranking-snapshot-operations.md @@ -1,7 +1,7 @@ --- id: TASK-177-harden-annual-ranking-snapshot-operations title: Harden annual ranking snapshot operations -status: pending +status: done type: backend team: Backend Senior supporting_teams: @@ -74,5 +74,45 @@ The annual snapshot flow is implemented and documented, but edge cases around mi ## Outcome -Document applied hardening, validated responses, known limits, and whether a future task is needed for scheduled/hardening automation. +Applied hardening: +- Added explicit annual year normalization in `backend/app/rcon_annual_rankings.py` for supported `1..9999` values. +- Added reusable limit normalization in the annual snapshot reader/generator with a bounded cap of `100`. +- Added response metadata for annual snapshot reads: + - `requested_limit` + - `effective_limit` + - `snapshot_limit` + - `item_count` +- Hardened effective-limit resolution so a ready snapshot serves no more rows than: + - the requested limit, + - the persisted snapshot limit, + - and the actual stored item count. +- Wrapped the annual route payload build in a `ValueError` guard so invalid annual requests return controlled `400` payloads. + +Documentation updated: + +- `docs/annual-ranking-snapshot-runbook.md` now documents annual limit metadata and explains why `effective_limit` can be lower than `requested_limit` for ready snapshots. + +Validated responses: + +- current year with `metric=kills` -> `200` +- past year with no guaranteed data -> controlled `200` with `ready` or `missing` depending on stored snapshot presence +- future year without snapshot -> `200` with `snapshot_status="missing"` +- unsupported metric -> `400` +- low limit `3` -> `200`, `requested_limit=3`, `effective_limit<=3` +- high limit `101` -> `400` +- unsupported year `10000` -> `400` + +Validation run: + +- Inline Python route validation for the annual endpoint cases above +- `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1` + +Known limits: + +- The API still keeps `limit` aligned with the served effective limit for ready snapshots so current frontend behavior remains unchanged. +- This task does not add scheduling or regeneration automation; it only hardens the existing read/generate contract. + +Future task recommendation: + +- If operations need stronger guarantees, create a follow-up task for scheduled annual snapshot generation/audit reporting rather than broadening this read-path hardening further. diff --git a/backend/app/payloads.py b/backend/app/payloads.py index acd45ac..e29be73 100644 --- a/backend/app/payloads.py +++ b/backend/app/payloads.py @@ -684,6 +684,10 @@ def build_annual_ranking_snapshot_payload( "server_id": result.get("server_id"), "metric": result.get("metric"), "limit": result.get("limit"), + "requested_limit": result.get("requested_limit"), + "effective_limit": result.get("effective_limit"), + "snapshot_limit": result.get("snapshot_limit"), + "item_count": result.get("item_count"), "source": result.get("source"), "snapshot_status": result.get("snapshot_status"), "generated_at": result.get("generated_at"), diff --git a/backend/app/rcon_annual_rankings.py b/backend/app/rcon_annual_rankings.py index ee67661..d7c4813 100644 --- a/backend/app/rcon_annual_rankings.py +++ b/backend/app/rcon_annual_rankings.py @@ -24,10 +24,11 @@ def generate_annual_ranking_snapshot( db_path: Path | None = None, ) -> dict[str, object]: """Generate and persist an annual top-k ranking snapshot for materialized RCON data.""" + normalized_year = _normalize_year(year) normalized_server_key = _normalize_server_key(server_key) normalized_metric = _normalize_metric(metric) - normalized_limit = max(1, int(limit)) - window_start, window_end = _annual_window(year) + normalized_limit = _normalize_limit(limit) + window_start, window_end = _annual_window(normalized_year) resolved_path = initialize_rcon_materialized_storage(db_path=db_path) scope_sql, scope_params = _build_scope_sql(normalized_server_key) @@ -48,7 +49,7 @@ def generate_annual_ranking_snapshot( ) existing_snapshot_id = _find_existing_snapshot( connection=connection, - year=year, + year=normalized_year, server_key=normalized_server_key, metric=normalized_metric, ) @@ -76,13 +77,13 @@ def generate_annual_ranking_snapshot( _delete_existing_snapshot( connection=connection, - year=year, + year=normalized_year, server_key=normalized_server_key, metric=normalized_metric, ) snapshot_id = _insert_snapshot( connection=connection, - year=year, + year=normalized_year, server_key=normalized_server_key, metric=normalized_metric, limit=normalized_limit, @@ -119,13 +120,10 @@ def get_annual_ranking_snapshot( db_path: Path | None = None, ) -> dict[str, object]: """Load one annual ranking snapshot without recalculating the ranking.""" - normalized_year = int(year) - if normalized_year < 1: - raise ValueError("year must be greater than zero") - + normalized_year = _normalize_year(year) normalized_server_key = _normalize_server_key(server_key) normalized_metric = _normalize_metric(metric) - normalized_limit = max(1, int(limit)) + normalized_limit = _normalize_limit(limit) resolved_path = initialize_rcon_materialized_storage(db_path=db_path) if use_postgres_rcon_storage(explicit_sqlite_path=db_path): @@ -149,6 +147,10 @@ def get_annual_ranking_snapshot( "server_id": normalized_server_key, "metric": normalized_metric, "limit": normalized_limit, + "requested_limit": normalized_limit, + "effective_limit": 0, + "snapshot_limit": None, + "item_count": 0, "source": "rcon-annual-ranking-snapshot", "generated_at": None, "window_start": None, @@ -157,11 +159,20 @@ def get_annual_ranking_snapshot( "items": [], } - effective_limit = min(normalized_limit, int(snapshot.get("limit_size") or normalized_limit)) + snapshot_limit = _normalize_limit(snapshot.get("limit_size") or normalized_limit) + item_count = _count_items( + connection=connection, + snapshot_id=int(snapshot["id"]), + ) + effective_limit = _resolve_effective_limit( + requested_limit=normalized_limit, + snapshot_limit=snapshot_limit, + item_count=item_count, + ) items = _list_items( connection=connection, snapshot_id=int(snapshot["id"]), - limit=effective_limit, + limit=effective_limit if effective_limit > 0 else None, ) return { @@ -170,6 +181,10 @@ def get_annual_ranking_snapshot( "server_id": normalized_server_key, "metric": normalized_metric, "limit": effective_limit, + "requested_limit": normalized_limit, + "effective_limit": effective_limit, + "snapshot_limit": snapshot_limit, + "item_count": item_count, "source": "rcon-annual-ranking-snapshot", "generated_at": snapshot.get("generated_at"), "window_start": snapshot.get("window_start"), @@ -194,6 +209,29 @@ def _normalize_metric(metric: str) -> str: return normalized +def _normalize_year(year: int) -> int: + normalized_year = int(year) + if normalized_year < 1 or normalized_year > 9999: + raise ValueError("year must be between 1 and 9999") + return normalized_year + + +def _normalize_limit(limit: object, *, maximum: int = 100) -> int: + normalized_limit = int(limit or 1) + if normalized_limit < 1: + raise ValueError("limit must be greater than zero") + return min(normalized_limit, maximum) + + +def _resolve_effective_limit( + *, + requested_limit: int, + snapshot_limit: int, + item_count: int, +) -> int: + return max(0, min(requested_limit, snapshot_limit, item_count)) + + def _annual_window(year: int) -> tuple[str, str]: start = datetime(year, 1, 1, 0, 0, 0, tzinfo=timezone.utc).isoformat().replace("+00:00", "Z") end = datetime(year + 1, 1, 1, 0, 0, 0, tzinfo=timezone.utc).isoformat().replace("+00:00", "Z") @@ -486,6 +524,18 @@ def _list_items(*, connection: object, snapshot_id: int, limit: int | None = Non return [dict(row) for row in rows] +def _count_items(*, connection: object, snapshot_id: int) -> int: + row = connection.execute( + """ + SELECT COUNT(*) AS item_count + FROM rcon_annual_ranking_snapshot_items + WHERE snapshot_id = ? + """, + [snapshot_id], + ).fetchone() + return int(row["item_count"] or 0) if row else 0 + + def _build_scope_sql(server_key: str, *, table_alias: str = "matches") -> tuple[str, list[object]]: if server_key == ALL_SERVERS_SLUG: return "", [] diff --git a/backend/app/routes.py b/backend/app/routes.py index 0a86ac4..df12341 100644 --- a/backend/app/routes.py +++ b/backend/app/routes.py @@ -101,12 +101,15 @@ def resolve_get_payload(path: str) -> tuple[HTTPStatus | None, dict[str, object] server_id = params.get("server_id", [None])[0] if server_id is None: server_id = params.get("server", [None])[0] - return HTTPStatus.OK, build_annual_ranking_snapshot_payload( - year=year, - server_id=server_id, - metric=metric, - limit=limit, - ) + try: + return HTTPStatus.OK, build_annual_ranking_snapshot_payload( + year=year, + server_id=server_id, + metric=metric, + limit=limit, + ) + except ValueError as error: + return HTTPStatus.BAD_REQUEST, build_error_payload(str(error)) if parsed.path == "/api/current-match": server_slug = parse_qs(parsed.query).get("server", [None])[0] diff --git a/docs/annual-ranking-snapshot-runbook.md b/docs/annual-ranking-snapshot-runbook.md index 4cb17da..e305957 100644 --- a/docs/annual-ranking-snapshot-runbook.md +++ b/docs/annual-ranking-snapshot-runbook.md @@ -94,6 +94,11 @@ Verificación local (reconstruir estado esperable): - `ready`: existe snapshot; - `missing`: no existe snapshot. 3. Verificar `generated_at`, `window_start`, `window_end`, `source_matches_count`. +4. Verificar metadatos de limite: + - `requested_limit`: limite pedido por el cliente; + - `snapshot_limit`: limite persistido en snapshot; + - `effective_limit`: limite realmente servido; + - `item_count`: filas actualmente disponibles. Se considera snapshot existente si API responde `snapshot_status="ready"`. @@ -109,6 +114,8 @@ Checklist: - HTTP 200 esperado para parámetros válidos de V1. - `status` debe ser `"ok"` con estructura de data consistente. +- Para snapshots `ready`, `effective_limit` puede ser menor que `requested_limit` + cuando el snapshot fue generado con un limite menor o contiene menos filas. - en V1 con `metric` no soportada, esperar error de request (400) sin recomputar ranking. ## 8) Como validar desde frontend Stats diff --git a/frontend/assets/css/styles.css b/frontend/assets/css/styles.css index 961ddaf..2c2d1ab 100644 --- a/frontend/assets/css/styles.css +++ b/frontend/assets/css/styles.css @@ -1069,6 +1069,123 @@ h2 { grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr)); } +.stats-comparison-grid { + display: grid; + gap: 12px; + margin: 16px 0; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr)); +} + +.stats-comparison-card { + position: relative; + overflow: hidden; + min-height: 100%; + border: 1px solid rgba(183, 201, 125, 0.18); + border-radius: 18px; + padding: 16px 18px; + background: + linear-gradient(180deg, rgba(30, 36, 27, 0.94), rgba(11, 14, 10, 0.98)); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.03), + var(--shadow-soft); +} + +.stats-comparison-card::before { + content: ""; + position: absolute; + inset: 0 0 auto; + height: 1px; + background: linear-gradient(90deg, rgba(210, 182, 118, 0.5), transparent 70%); + pointer-events: none; +} + +.stats-comparison-card__eyebrow { + margin: 0 0 8px; + color: var(--accent-warm); + font-size: 0.7rem; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.stats-comparison-card__title { + margin: 0 0 8px; + font-size: 1rem; + line-height: 1.4; +} + +.stats-comparison-card__badge { + display: inline-flex; + align-items: center; + min-height: 34px; + margin-bottom: 12px; + padding: 0.4rem 0.8rem; + border: 1px solid rgba(159, 168, 141, 0.22); + border-radius: 999px; + background: rgba(15, 18, 13, 0.84); + color: var(--text-soft); + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.stats-comparison-card__badge--ok { + border-color: rgba(183, 201, 125, 0.34); + color: var(--accent-strong); +} + +.stats-comparison-card__badge--warning { + border-color: rgba(210, 182, 118, 0.32); + color: #e2d7a9; +} + +.stats-comparison-card__badge--error { + border-color: rgba(210, 182, 118, 0.45); + color: var(--accent-warm); +} + +.stats-comparison-card__grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + margin-bottom: 12px; +} + +.stats-comparison-card__metric { + padding: 10px 12px; + border: 1px solid rgba(159, 168, 141, 0.14); + border-radius: 12px; + background: linear-gradient(180deg, rgba(15, 18, 13, 0.5), rgba(8, 10, 7, 0.3)); +} + +.stats-comparison-card__metric-label { + display: block; + margin-bottom: 6px; + color: var(--muted); + font-size: 0.74rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.stats-comparison-card__metric-value { + display: block; + color: var(--text); + font-size: 1.02rem; + font-weight: 700; +} + +.stats-comparison-card__detail, +.stats-comparison-card__note { + margin: 0; + color: var(--text-soft); + line-height: 1.6; +} + +.stats-comparison-card__detail + .stats-comparison-card__detail, +.stats-comparison-card__detail + .stats-comparison-card__note { + margin-top: 8px; +} + .stats-summary-card { margin: 0; border: 1px solid rgba(159, 168, 141, 0.2); @@ -1171,6 +1288,10 @@ h2 { grid-template-columns: 1fr; } + .stats-comparison-card__grid { + grid-template-columns: 1fr; + } + .stats-search-form__fields .discord-button { width: 100%; } diff --git a/frontend/assets/js/stats.js b/frontend/assets/js/stats.js index 0a17e24..453962c 100644 --- a/frontend/assets/js/stats.js +++ b/frontend/assets/js/stats.js @@ -9,6 +9,7 @@ document.addEventListener("DOMContentLoaded", () => { const profilePanel = document.getElementById("stats-profile-panel"); const profileTitle = document.getElementById("stats-profile-title"); const profileStateNode = document.getElementById("stats-profile-state"); + const comparisonGrid = document.getElementById("stats-comparison-grid"); const summaryGrid = document.getElementById("stats-summary-grid"); const weeklySummaryNode = document.getElementById("stats-weekly-summary"); const monthlySummaryNode = document.getElementById("stats-monthly-summary"); @@ -143,6 +144,9 @@ document.addEventListener("DOMContentLoaded", () => { profilePanel.hidden = false; profileStateNode.textContent = message; profileStateNode.className = `stats-state stats-state--${state}`; + if (comparisonGrid) { + comparisonGrid.innerHTML = ""; + } summaryGrid.innerHTML = ""; if (weeklySummaryNode) { weeklySummaryNode.textContent = "Cargando ..."; @@ -423,24 +427,17 @@ document.addEventListener("DOMContentLoaded", () => { } try { - const response = await fetch( - `${backendBaseUrl}/api/stats/players/${encodeURIComponent(playerId)}?timeframe=weekly`, - ); - if (!response.ok) { - throw new Error(`Profile request failed with ${response.status}`); - } - - const payload = await response.json(); - if (!payload || payload.status !== "ok") { - throw new Error(payload?.message || "Respuesta de perfil invalida"); - } - - const data = payload.data || {}; + const [weeklyData, monthlyData] = await Promise.all([ + fetchPlayerProfile(playerId, "weekly"), + fetchPlayerProfile(playerId, "monthly"), + ]); profilePanel.hidden = false; profileTitle.textContent = messages.profileReadyTitle; - const playerName = String(data.player_name || "Sin nombre"); - const playerIdText = String(data.player_id || playerId); - const hasStats = Number(data.matches_considered || 0) > 0; + const playerName = String(weeklyData.player_name || monthlyData.player_name || "Sin nombre"); + const playerIdText = String(weeklyData.player_id || monthlyData.player_id || playerId); + const hasWeeklyStats = Number(weeklyData.matches_considered || 0) > 0; + const hasMonthlyStats = Number(monthlyData.matches_considered || 0) > 0; + const hasStats = hasWeeklyStats || hasMonthlyStats; profileStateNode.textContent = hasStats ? `${playerName} (${playerIdText})` @@ -454,29 +451,36 @@ document.addEventListener("DOMContentLoaded", () => {

Identidad

Jugador: ${escapeHtml(playerName)}

ID: ${escapeHtml(playerIdText)}

-

Partidas: ${safeInt(data.matches_considered, 0)}

+

Partidas semanales: ${safeInt(weeklyData.matches_considered, 0)}

+

Partidas mensuales: ${safeInt(monthlyData.matches_considered, 0)}

-

Totales

-

Kills: ${safeInt(data.kills, 0)}

-

Deaths: ${safeInt(data.deaths, 0)}

-

Teamkills: ${safeInt(data.teamkills, 0)}

+

Ventana semanal

+

Kills: ${safeInt(weeklyData.kills, 0)}

+

Deaths: ${safeInt(weeklyData.deaths, 0)}

+

Teamkills: ${safeInt(weeklyData.teamkills, 0)}

-

Ratios

-

K/D: ${safeDecimal(data.kd_ratio, 2, "0.00")}

-

Kills/partida: ${safeDecimal(data.kills_per_match, 2, "0.00")}

-

Deaths/partida: ${safeDecimal(data.deaths_per_match, 2, "0.00")}

+

Ventana mensual

+

Kills: ${safeInt(monthlyData.kills, 0)}

+

Deaths: ${safeInt(monthlyData.deaths, 0)}

+

Teamkills: ${safeInt(monthlyData.teamkills, 0)}

`; + if (comparisonGrid) { + comparisonGrid.innerHTML = renderComparisonCards(weeklyData, monthlyData); + } + weeklySummaryNode.innerHTML = formatRankingSummary( - data.weekly_ranking, + weeklyData.weekly_ranking, "Semanal", + weeklyData.matches_considered, ); monthlySummaryNode.innerHTML = formatRankingSummary( - data.monthly_ranking, + monthlyData.monthly_ranking, "Mensual", + monthlyData.matches_considered, ); setBackendState(messages.backendOnline, true); @@ -490,6 +494,9 @@ document.addEventListener("DOMContentLoaded", () => { profileTitle.textContent = messages.profileReadyTitle; profileStateNode.textContent = messages.profileError; profileStateNode.className = "stats-state stats-state--error"; + if (comparisonGrid) { + comparisonGrid.innerHTML = ""; + } summaryGrid.innerHTML = ""; if (weeklySummaryNode) { weeklySummaryNode.textContent = messages.weeklyPlaceholder; @@ -507,6 +514,9 @@ document.addEventListener("DOMContentLoaded", () => { if (openPanel) { profilePanel.hidden = true; } + if (comparisonGrid) { + comparisonGrid.innerHTML = ""; + } summaryGrid.innerHTML = ""; if (weeklySummaryNode) { weeklySummaryNode.textContent = messages.weeklyPlaceholder; @@ -518,27 +528,213 @@ document.addEventListener("DOMContentLoaded", () => { profileStateNode.className = "stats-state stats-state--neutral"; } - function formatRankingSummary(ranking, timeframeLabel) { - if (!ranking || !Number.isFinite(safeParseNumber(ranking.ranking_position))) { - return `Sin ranking ${timeframeLabel.toLowerCase()} registrado para este jugador.`; + async function fetchPlayerProfile(playerId, timeframe) { + const response = await fetch( + `${backendBaseUrl}/api/stats/players/${encodeURIComponent(playerId)}?timeframe=${encodeURIComponent(timeframe)}`, + ); + if (!response.ok) { + throw new Error(`Profile request failed with ${response.status}`); + } + + const payload = await response.json(); + if (!payload || payload.status !== "ok") { + throw new Error(payload?.message || "Respuesta de perfil invalida"); + } + + return payload.data || {}; + } + + function renderComparisonCards(weeklyData, monthlyData) { + return [ + renderWindowComparisonCard("Semanal", weeklyData, weeklyData.weekly_ranking), + renderWindowComparisonCard("Mensual", monthlyData, monthlyData.monthly_ranking), + renderDeltaComparisonCard(weeklyData, monthlyData), + ].join(""); + } + + function renderWindowComparisonCard(label, data, ranking) { + const rankingState = describeRankingState(ranking, data?.matches_considered, label); + const matches = safeInt(data?.matches_considered, 0); + const kills = safeInt(data?.kills, 0); + const deaths = safeInt(data?.deaths, 0); + const kdRatio = safeDecimal(data?.kd_ratio, 2, "0.00"); + const killsPerMatch = safeDecimal(data?.kills_per_match, 2, "0.00"); + + return ` +
+

Comparativa ${label.toLowerCase()}

+

Ventana ${label.toLowerCase()}

+ + ${escapeHtml(rankingState.title)} + +
+
+ Partidas + ${matches} +
+
+ Kills + ${kills} +
+
+ Deaths + ${deaths} +
+
+ K/D + ${kdRatio} +
+
+

+ Kills por partida: ${killsPerMatch} +

+

${escapeHtml(rankingState.detail)}

+
+ `; + } + + function renderDeltaComparisonCard(weeklyData, monthlyData) { + const weeklyKpm = safeParseNumber(weeklyData?.kills_per_match); + const monthlyKpm = safeParseNumber(monthlyData?.kills_per_match); + const weeklyKd = safeParseNumber(weeklyData?.kd_ratio); + const monthlyKd = safeParseNumber(monthlyData?.kd_ratio); + const killsDelta = safeInt(monthlyData?.kills, 0) - safeInt(weeklyData?.kills, 0); + const matchesDelta = + safeInt(monthlyData?.matches_considered, 0) - safeInt(weeklyData?.matches_considered, 0); + + return ` +
+

Lectura comparada

+

Pulso semanal vs mensual

+ + ${escapeHtml(buildDeltaHeadline(killsDelta, matchesDelta))} + +
+
+ KPM semanal + ${safeDecimal(weeklyKpm, 2, "0.00")} +
+
+ KPM mensual + ${safeDecimal(monthlyKpm, 2, "0.00")} +
+
+ K/D semanal + ${safeDecimal(weeklyKd, 2, "0.00")} +
+
+ K/D mensual + ${safeDecimal(monthlyKd, 2, "0.00")} +
+
+

+ Delta de kills: ${formatSignedNumber(killsDelta)} +

+

+ Delta de partidas: ${formatSignedNumber(matchesDelta)} +

+

+ La lectura compara solo datos existentes del backend actual, sin recalcular rankings ni pedir nuevos endpoints. +

+
+ `; + } + + function formatRankingSummary(ranking, timeframeLabel, matchesConsidered) { + const rankingState = describeRankingState(ranking, matchesConsidered, timeframeLabel); + if (!ranking) { + return escapeHtml(rankingState.detail); + } + + if (!Number.isFinite(safeParseNumber(ranking.ranking_position))) { + return ` +

Estado: ${escapeHtml(rankingState.title)}

+

${escapeHtml(rankingState.detail)}

+

${escapeHtml(formatWindowRange(ranking))}

+ `; } const rank = safeInt(ranking.ranking_position, 0); const metric = escapeHtml(String(ranking.metric || annualMetric)); - const windowLabel = escapeHtml(String(ranking.window_kind || "")); - const windowStart = formatDateTime(ranking.window_start); - const windowEnd = formatDateTime(ranking.window_end); - const windowRange = windowLabel - ? `${windowLabel}: ${windowStart} - ${windowEnd}` - : `Ventana: ${windowStart} - ${windowEnd}`; return `

Posicion: ${rank}

Metric: ${metric}

-

${windowRange}

+

${escapeHtml(formatWindowRange(ranking))}

`; } + function describeRankingState(ranking, matchesConsidered, timeframeLabel) { + const matches = safeInt(matchesConsidered, 0); + const label = String(timeframeLabel || "ranking").toLowerCase(); + if (!ranking) { + return { + tone: "error", + title: "Ranking ausente", + detail: `No se recibio bloque de ranking ${label} desde el backend.`, + }; + } + + if (Number.isFinite(safeParseNumber(ranking.ranking_position))) { + return { + tone: "ok", + title: `Posicion #${safeInt(ranking.ranking_position, 0)}`, + detail: `Jugador posicionado en el ranking ${label} activo por kills.`, + }; + } + + if (matches <= 0) { + return { + tone: "warning", + title: "Sin actividad", + detail: `No hay actividad cerrada del jugador en la ventana ${label} actual.`, + }; + } + + const windowKind = String(ranking.window_kind || "").toLowerCase(); + if (windowKind.startsWith("previous-")) { + return { + tone: "warning", + title: "Profundidad insuficiente", + detail: `La ventana ${label} activa usa fallback previo y el jugador no aparece posicionado.`, + }; + } + + return { + tone: "warning", + title: "Fuera del ranking visible", + detail: `El jugador tiene actividad en la ventana ${label}, pero no figura en el ranking visible actual.`, + }; + } + + function buildDeltaHeadline(killsDelta, matchesDelta) { + if (killsDelta === 0 && matchesDelta === 0) { + return "Ritmo estable"; + } + if (killsDelta > 0 || matchesDelta > 0) { + return "Presion mensual superior"; + } + return "Ventana corta mas intensa"; + } + + function formatSignedNumber(value) { + const parsed = safeInt(value, 0); + if (parsed > 0) { + return `+${parsed}`; + } + return String(parsed); + } + + function formatWindowRange(ranking) { + const windowLabel = String(ranking?.window_kind || "").trim(); + const windowStart = formatDateTime(ranking?.window_start); + const windowEnd = formatDateTime(ranking?.window_end); + if (windowLabel) { + return `${windowLabel}: ${windowStart} - ${windowEnd}`; + } + return `Ventana: ${windowStart} - ${windowEnd}`; + } + function safeInt(value, fallback = 0) { const parsed = Number(value); if (!Number.isFinite(parsed)) { diff --git a/frontend/stats.html b/frontend/stats.html index 074bdf3..d242a0d 100644 --- a/frontend/stats.html +++ b/frontend/stats.html @@ -86,6 +86,7 @@

Selecciona un jugador para ver su panel personal.

+
diff --git a/scripts/run-integration-tests.ps1 b/scripts/run-integration-tests.ps1 index 7fab467..5fd85d0 100644 --- a/scripts/run-integration-tests.ps1 +++ b/scripts/run-integration-tests.ps1 @@ -2,6 +2,14 @@ $ErrorActionPreference = "Stop" Write-Host "HLL Vietnam platform validation" +function Assert-LastExitCode { + param([string] $Message) + + if ($LASTEXITCODE -ne 0) { + throw $Message + } +} + $configPath = "ai-platform.json" if (-not (Test-Path $configPath)) { throw "Missing $configPath" @@ -54,8 +62,12 @@ if status is None or payload.get("status") != "ok": '@ $backendImportCheck | python - +Assert-LastExitCode "Backend startup import check failed." powershell -ExecutionPolicy Bypass -File scripts/run-historical-ui-regression-tests.ps1 +Assert-LastExitCode "Historical UI regression validation failed." +powershell -ExecutionPolicy Bypass -File scripts/run-stats-validation.ps1 +Assert-LastExitCode "Stats regression validation failed." Write-Host "No product integration tests are configured for this platform-only scope." Write-Host "Backend startup import check passed." diff --git a/scripts/run-stats-validation.ps1 b/scripts/run-stats-validation.ps1 new file mode 100644 index 0000000..f79815d --- /dev/null +++ b/scripts/run-stats-validation.ps1 @@ -0,0 +1,226 @@ +$ErrorActionPreference = "Stop" + +Write-Host "Stats regression validation" + +function Assert-FileExists { + param( + [string] $Path, + [string] $Message + ) + + if (-not (Test-Path $Path)) { + throw $Message + } +} + +function Assert-Contains { + param( + [string] $Content, + [string] $Pattern, + [string] $Message + ) + + if ($Content -notmatch [regex]::Escape($Pattern)) { + throw $Message + } +} + +function Get-HttpStatusCode { + param([string] $Url) + + try { + $response = Invoke-WebRequest -Uri $Url -UseBasicParsing -TimeoutSec 5 + return [int] $response.StatusCode + } catch [System.Net.WebException] { + if ($_.Exception.Response) { + return [int] $_.Exception.Response.StatusCode + } + throw + } +} + +function Assert-LastExitCode { + param([string] $Message) + + if ($LASTEXITCODE -ne 0) { + throw $Message + } +} + +Assert-FileExists "frontend/stats.html" "Missing frontend/stats.html" +Assert-FileExists "frontend/assets/js/stats.js" "Missing frontend/assets/js/stats.js" + +$statsHtml = Get-Content -Raw "frontend/stats.html" +$statsJs = Get-Content -Raw "frontend/assets/js/stats.js" + +Assert-Contains $statsHtml 'id="stats-search-form"' ` + "Stats page no longer exposes the player search form." +Assert-Contains $statsHtml 'id="stats-profile-panel"' ` + "Stats page no longer exposes the player profile panel." +Assert-Contains $statsHtml 'id="stats-annual-form"' ` + "Stats page no longer exposes the annual ranking form." +Assert-Contains $statsHtml './assets/js/stats.js' ` + "Stats page no longer loads the Stats JavaScript asset." +Assert-Contains $statsJs "/api/stats/players/search" ` + "Stats frontend no longer targets the player search endpoint." +Assert-Contains $statsJs "/api/stats/rankings/annual" ` + "Stats frontend no longer targets the annual ranking endpoint." +Assert-Contains $statsJs "/api/stats/players/" ` + "Stats frontend no longer targets the player profile endpoint." +Assert-Contains $statsJs "Backend no disponible" ` + "Stats frontend no longer exposes the controlled backend-unavailable messaging." + +$backendContractCheck = @' +import json +import sys +from datetime import datetime, timezone + +sys.path.insert(0, "backend") + +from app.routes import resolve_get_payload + + +def require(condition, message): + if not condition: + raise SystemExit(message) + + +def read_payload(path): + status, payload = resolve_get_payload(path) + require(status is not None, f"{path} did not resolve") + return int(status), payload + + +health_status, health_payload = read_payload("/health") +require(health_status == 200, "Route resolver /health no longer returns 200.") +require(health_payload.get("status") == "ok", "Route resolver /health no longer returns ok status.") + +search_status, search_payload = read_payload("/api/stats/players/search?q=regression-check&limit=5") +require(search_status == 200, "Stats player search should return 200 for a valid query.") +search_data = search_payload.get("data") or {} +require(search_payload.get("status") == "ok", "Stats player search payload no longer returns ok status.") +require(search_data.get("query") == "regression-check", "Stats player search no longer echoes query.") +require(search_data.get("server_id") == "all-servers", "Stats player search no longer defaults server scope to all-servers.") +require(isinstance(search_data.get("items"), list), "Stats player search items must remain a list.") + +missing_query_status, missing_query_payload = read_payload("/api/stats/players/search") +require(missing_query_status == 400, "Stats player search without q must return 400.") +require("required" in str(missing_query_payload.get("message", "")).lower(), "Missing-q error message changed unexpectedly.") + +invalid_search_limit_status, _ = read_payload("/api/stats/players/search?q=regression-check&limit=0") +require(invalid_search_limit_status == 400, "Stats player search with limit=0 must return 400.") + +profile_status, profile_payload = read_payload("/api/stats/players/regression-player?timeframe=weekly") +require(profile_status == 200, "Stats player profile should return 200 for a valid player lookup.") +profile_data = profile_payload.get("data") or {} +require(profile_payload.get("status") == "ok", "Stats player profile payload no longer returns ok status.") +require(profile_data.get("player_id") == "regression-player", "Stats player profile no longer preserves player id.") +require(profile_data.get("timeframe") == "weekly", "Stats player profile no longer preserves timeframe.") +require(profile_data.get("server_id") == "all-servers", "Stats player profile no longer defaults server scope to all-servers.") +require(isinstance(profile_data.get("matches_considered"), int), "Stats player profile matches_considered must remain an int.") +require(isinstance(profile_data.get("source"), dict), "Stats player profile source metadata must remain present.") +require(isinstance(profile_data.get("weekly_ranking"), (dict, type(None))), "Stats player profile weekly ranking shape changed unexpectedly.") +require(isinstance(profile_data.get("monthly_ranking"), (dict, type(None))), "Stats player profile monthly ranking shape changed unexpectedly.") + +invalid_timeframe_status, _ = read_payload("/api/stats/players/regression-player?timeframe=seasonal") +require(invalid_timeframe_status == 400, "Stats player profile with invalid timeframe must return 400.") + +current_year = datetime.now(timezone.utc).year +annual_status, annual_payload = read_payload( + f"/api/stats/rankings/annual?year={current_year}&server_id=all&metric=kills&limit=20" +) +require(annual_status == 200, "Annual ranking should return 200 for metric=kills.") +annual_data = annual_payload.get("data") or {} +require(annual_payload.get("status") == "ok", "Annual ranking payload no longer returns ok status.") +require(annual_data.get("year") == current_year, "Annual ranking no longer preserves requested year.") +require(annual_data.get("server_id") == "all-servers", "Annual ranking no longer preserves the normalized all-servers scope.") +require(annual_data.get("metric") == "kills", "Annual ranking metric changed unexpectedly.") +require( + isinstance(annual_data.get("limit"), int) and 1 <= annual_data.get("limit") <= 20, + "Annual ranking limit must remain a positive int capped by the requested value.", +) +require(annual_data.get("snapshot_status") in {"ready", "missing"}, "Annual ranking snapshot status must remain ready or missing.") +require(isinstance(annual_data.get("items"), list), "Annual ranking items must remain a list.") + +low_limit_status, low_limit_payload = read_payload( + f"/api/stats/rankings/annual?year={current_year}&server_id=all&metric=kills&limit=3" +) +require(low_limit_status == 200, "Annual ranking with low limit must return 200.") +low_limit_value = (low_limit_payload.get("data") or {}).get("limit") +require( + isinstance(low_limit_value, int) and 1 <= low_limit_value <= 3, + "Annual ranking limit normalization for low limits changed unexpectedly.", +) + +high_limit_status, _ = read_payload( + f"/api/stats/rankings/annual?year={current_year}&server_id=all&metric=kills&limit=101" +) +require(high_limit_status == 400, "Annual ranking with limit=101 must return 400.") + +unsupported_metric_status, unsupported_metric_payload = read_payload( + f"/api/stats/rankings/annual?year={current_year}&server_id=all&metric=deaths&limit=20" +) +require(unsupported_metric_status == 400, "Annual ranking with unsupported metric must return 400.") +require("metric" in str(unsupported_metric_payload.get("message", "")).lower(), "Unsupported metric error message changed unexpectedly.") + +missing_year_status, _ = read_payload("/api/stats/rankings/annual?year=2999&server_id=all&metric=kills&limit=20") +require(missing_year_status == 200, "Annual ranking future-year missing snapshot must still return 200.") + +print(json.dumps({ + "checked": [ + "health", + "stats-player-search", + "stats-player-profile", + "stats-annual-ranking", + ], + "annual_snapshot_status": annual_data.get("snapshot_status"), + "search_items_count": len(search_data.get("items") or []), + "profile_matches_considered": profile_data.get("matches_considered"), +})) +'@ + +$backendContractCheck | python - +Assert-LastExitCode "Stats route-contract validation failed." + +$backendBaseUrl = "http://127.0.0.1:8000" +$backendAvailable = $false + +try { + $healthPayload = Invoke-RestMethod -Uri "$backendBaseUrl/health" -TimeoutSec 5 + if ($healthPayload.status -ne "ok") { + throw "Live backend health payload did not return status=ok." + } + $backendAvailable = $true + Write-Host "Live backend available at $backendBaseUrl" +} catch { + Write-Warning "Live backend unavailable at $backendBaseUrl. Route-contract checks passed via local Python imports." + Write-Host "Next steps: start the backend, then rerun scripts/run-stats-validation.ps1 to verify live HTTP responses." + Write-Host "Expected limited behavior while offline: stats.html should show backend-unavailable states for search, profile, and annual ranking." +} + +if ($backendAvailable) { + $currentYear = (Get-Date).ToUniversalTime().Year + $searchPayload = Invoke-RestMethod -Uri "$backendBaseUrl/api/stats/players/search?q=regression-check&limit=5" -TimeoutSec 5 + if ($searchPayload.status -ne "ok") { + throw "Live stats search no longer returns status=ok." + } + + $profilePayload = Invoke-RestMethod -Uri "$backendBaseUrl/api/stats/players/regression-player?timeframe=weekly" -TimeoutSec 5 + if ($profilePayload.status -ne "ok") { + throw "Live stats profile no longer returns status=ok." + } + + $annualPayload = Invoke-RestMethod -Uri "$backendBaseUrl/api/stats/rankings/annual?year=$currentYear&server_id=all&metric=kills&limit=20" -TimeoutSec 5 + if ($annualPayload.status -ne "ok") { + throw "Live annual ranking no longer returns status=ok." + } + + $unsupportedMetricStatus = Get-HttpStatusCode -Url "$backendBaseUrl/api/stats/rankings/annual?year=$currentYear&server_id=all&metric=deaths&limit=20" + if ($unsupportedMetricStatus -ne 400) { + throw "Live annual ranking with unsupported metric must return HTTP 400." + } + + Write-Host "Live HTTP checks passed for Stats endpoints." +} + +Write-Host "Stats regression validation passed."