Add global ranking page

This commit is contained in:
devRaGonSa
2026-06-08 19:14:29 +02:00
parent 05d9939a23
commit dbfa28f8a4
13 changed files with 1441 additions and 232 deletions

View File

@@ -1,7 +1,7 @@
--- ---
id: TASK-180-define-global-ranking-page-contract id: TASK-180-define-global-ranking-page-contract
title: Define global ranking page contract title: Define global ranking page contract
status: pending status: done
type: documentation type: documentation
team: Analista team: Analista
supporting_teams: supporting_teams:
@@ -21,33 +21,33 @@ Define a separate, explicit global ranking page contract that is clearly distinc
## Context ## Context
Stats focuses on one player workflow (search and personal performance), while Ranking will expose public top lists. This task formalizes the Ranking contract, filters, payload shape, and UI states needed for an incremental, RCON-first implementation. Stats focuses on one player workflow (search and personal performance), while Ranking exposes public top lists. This task formalizes the Ranking contract, filters, payload shape and UI states needed for an incremental, RCON-first implementation.
## Steps ## Steps
1. Read the listed files first. 1. Read the listed files first.
2. Draft a concise, implementation-ready contract for Ranking (purpose, data sources, filters, payload, and states). 2. Draft a concise, implementation-ready contract for Ranking.
3. Document how Ranking and Stats are separated and where they can share frontend patterns safely. 3. Document how Ranking and Stats are separated and where they can share frontend patterns safely.
4. Define API expectations for weekly/monthly vs annual reads, including missing/ready/error behavior. 4. Define API expectations for weekly/monthly vs annual reads, including missing/ready/error behavior.
5. Keep constraints explicit (no Elo/MMR, no Comunidad Hispana #03, no public scoreboard as primary source when RCON exists). 5. Keep constraints explicit.
6. Capture follow-up items and leave a clear next-task suggestion. 6. Capture follow-up items and a clear next-task suggestion.
## Files to Read First ## Files to Read First
- AGENTS.md - `AGENTS.md`
- ai/repo-context.md - `ai/repo-context.md`
- ai/architecture-index.md - `ai/architecture-index.md`
- docs/stats-section-functional-plan.md - `docs/stats-section-functional-plan.md`
- docs/annual-ranking-snapshot-runbook.md - `docs/annual-ranking-snapshot-runbook.md`
- backend/app/rcon_historical_leaderboards.py - `backend/app/rcon_historical_leaderboards.py`
- backend/app/rcon_annual_rankings.py - `backend/app/rcon_annual_rankings.py`
- frontend/stats.html - `frontend/stats.html`
- frontend/assets/js/stats.js - `frontend/assets/js/stats.js`
## Expected Files to Modify ## Expected Files to Modify
- docs/global-ranking-page-plan.md - `docs/global-ranking-page-plan.md`
- ai/tasks/done/TASK-180-define-global-ranking-page-contract.md - `ai/tasks/done/TASK-180-define-global-ranking-page-contract.md`
## Constraints ## Constraints
@@ -60,20 +60,22 @@ Stats focuses on one player workflow (search and personal performance), while Ra
- No reintroduction of Comunidad Hispana #03. - No reintroduction of Comunidad Hispana #03.
- No public-scoreboard as primary source when RCON coverage is available. - No public-scoreboard as primary source when RCON coverage is available.
## Validation
- Confirm `docs/global-ranking-page-plan.md` exists.
- Confirm no backend/frontend files were modified.
- Run `git diff --name-only` within task scope.
- Document explicitly that no automated tests apply.
## Outcome ## Outcome
- Documented Ranking goal and how it differs from Stats. - Created `docs/global-ranking-page-plan.md`.
- Documented user flow: - Documented a dedicated `Ranking` page contract separate from `Stats`.
- open Ranking page - Defined dedicated endpoint direction as `GET /api/ranking`.
- choose timeframe - Documented timeframe behavior:
- choose server - weekly/monthly from RCON materialized leaderboard reads
- choose metric - annual from persisted annual snapshots
- view top players - Documented required payload fields:
- change filters without manual reload where feasible
- Documented expected API contract:
- weekly/monthly from RCON materialized leaderboard model
- annual from existing annual snapshot model
- Documented payload contract including:
- `ranking_position` - `ranking_position`
- `player_id` - `player_id`
- `player_name` - `player_name`
@@ -85,26 +87,25 @@ Stats focuses on one player workflow (search and personal performance), while Ra
- `kd_ratio` - `kd_ratio`
- `window_start` - `window_start`
- `window_end` - `window_end`
- `snapshot_status` where applicable - `snapshot_status`
- Documented UI states: - Documented required UI states:
- loading - loading
- backend offline - backend offline
- no data - no data
- annual snapshot missing - annual snapshot missing
- unsupported metric - unsupported metric
- controlled error - controlled error
- Documented non-goals: - Explicitly preserved constraints:
- Elo/MMR - no Elo/MMR
- authentication - no Comunidad Hispana #03
- private profile expansion - no public-scoreboard primary path while RCON exists
- advanced charts
- large database changes
- reintroducing Comunidad Hispana #03
## Validation Validation performed:
- Confirm `docs/global-ranking-page-plan.md` exists. - Confirmed documentation file creation.
- Confirm no backend/frontend files were modified. - Kept changes out of `backend/` and `frontend/`.
- Run `git diff --name-only` within task scope. - Automated tests do not apply to this documentation-only task.
- If no automated tests apply, document that explicitly in Outcome.
Recommended next task:
- `TASK-181-add-global-ranking-backend-support`

View File

@@ -0,0 +1,110 @@
---
id: TASK-181-add-global-ranking-backend-support
title: Add global ranking backend support
status: done
type: backend
team: Backend Senior
supporting_teams:
- Arquitecto Python
- Arquitecto de Base de Datos
roadmap_item: foundation
priority: high
---
# TASK-181-add-global-ranking-backend-support - Add global ranking backend support
## Goal
Implement backend API support for the new global ranking page using existing RCON historical leaderboard logic and annual snapshot readers, without changing public Stats endpoints.
## Context
The backend now exposes a dedicated global ranking route that supports weekly, monthly and annual modes with V1 metric scope limited to `kills`.
## Files to Read First
- `AGENTS.md`
- `ai/repo-context.md`
- `ai/architecture-index.md`
- `docs/global-ranking-page-plan.md`
- `docs/stats-section-functional-plan.md`
- `docs/annual-ranking-snapshot-runbook.md`
- `backend/app/routes.py`
- `backend/app/payloads.py`
- `backend/app/rcon_historical_leaderboards.py`
- `backend/app/rcon_annual_rankings.py`
- `scripts/run-stats-validation.ps1`
- `scripts/run-integration-tests.ps1`
## Expected Files to Modify
- `backend/app/routes.py`
- `backend/app/payloads.py`
- `scripts/run-stats-validation.ps1`
- `ai/tasks/done/TASK-181-add-global-ranking-backend-support.md`
## Constraints Verified
- No new architecture was introduced.
- No migrations were added.
- Annual ranking remains snapshot-backed and is not recalculated per request.
- Stats endpoints were left compatible.
- Comunidad Hispana #03 was not exposed through the new route.
- Public scoreboard was not introduced as a primary ranking source.
## Outcome
- Added `GET /api/ranking`.
- Added route validation for:
- `timeframe`
- `metric`
- `limit`
- `server_id`
- required `year` when `timeframe=annual`
- Reused existing readers:
- weekly/monthly from materialized RCON leaderboard reads
- annual from annual snapshot storage
- Normalized the dedicated ranking response with:
- `page_kind`
- `timeframe`
- `server_id`
- `metric`
- `limit`
- `requested_limit`
- `effective_limit`
- `window_start`
- `window_end`
- `snapshot_status`
- `source`
- `items`
- Updated regression validation to cover the new route and its invalid-input cases.
## Validation
Executed:
- `powershell -ExecutionPolicy Bypass -File scripts/run-stats-validation.ps1`
- `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1`
- Direct local route checks through `resolve_get_payload(...)` for:
- weekly `metric=kills`
- monthly `metric=kills`
- annual `metric=kills`
- annual missing `year`
Observed:
- Weekly route returned `200`.
- Monthly route returned `200`.
- Annual route returned `200`.
- Annual route without `year` returned `400`.
- Unsupported metric returned `400`.
- Unsupported timeframe returned `400`.
- High invalid limit returned `400`.
Known limitation:
- Live HTTP verification against a running backend at `http://127.0.0.1:8000` was not available during validation; route-contract checks passed through local Python imports and the integration script reported that explicitly.
## Recommended Next Task
- `TASK-182-add-global-ranking-frontend-page`

View File

@@ -0,0 +1,99 @@
---
id: TASK-182-add-global-ranking-frontend-page
title: Add global ranking frontend page
status: done
type: frontend
team: Frontend Senior
supporting_teams:
- Experto en interfaz
- Disenador grafico
- Backend Senior
roadmap_item: foundation
priority: high
---
# TASK-182-add-global-ranking-frontend-page - Add global ranking frontend page
## Goal
Create a dedicated Ranking page that displays public top lists by timeframe, server, metric and limit, consuming the backend support built in TASK-181.
## Context
Ranking is now separated from Stats in both navigation and interaction model. Stats remains player-centric; Ranking is list-centric and links back to Stats only when a user wants individual lookup.
## Files to Read First
- `AGENTS.md`
- `ai/repo-context.md`
- `ai/architecture-index.md`
- `docs/global-ranking-page-plan.md`
- `frontend/index.html`
- `frontend/historico.html`
- `frontend/stats.html`
- `frontend/assets/js/stats.js`
- `frontend/assets/css/styles.css`
- `backend/app/routes.py`
- `ai/tasks/done/TASK-181-add-global-ranking-backend-support.md`
## Expected Files Modified
- `frontend/ranking.html`
- `frontend/assets/js/ranking.js`
- `frontend/assets/css/styles.css`
- `frontend/index.html`
- `ai/tasks/done/TASK-182-add-global-ranking-frontend-page.md`
## Constraints Verified
- No backend files were changed in this task.
- No new endpoints were added.
- No database changes were made.
- No Elo/MMR reactivation.
- No Comunidad Hispana #03 reintroduction.
- Stats behavior was left intact.
- Implementation stayed in vanilla HTML/CSS/JS.
## Outcome
- Added `frontend/ranking.html` as a dedicated Ranking page.
- Added `frontend/assets/js/ranking.js` to:
- check backend health
- request `/api/ranking`
- switch between weekly, monthly and annual flows
- render loading, offline, no-data, annual-missing and controlled-error states
- Extended shared styling in `frontend/assets/css/styles.css` for:
- ranking filter layout
- ranking metadata cards
- ranking table
- ranking empty state
- Added a minimal `Ranking` entry point in `frontend/index.html`.
- Kept cross-linking minimal by linking from Ranking to Stats without modifying `stats.html`.
## Validation
Executed:
- `node --check frontend/assets/js/ranking.js`
- `node --check frontend/assets/js/stats.js`
- `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1`
- Local static serving with `python -m http.server`
HTTP checks:
- `ranking.html` -> `200`
- `assets/js/ranking.js` -> `200`
- `stats.html` -> `200`
Offline-state check:
- Backend was unavailable at `http://127.0.0.1:8000`.
- A headless Edge DOM capture of `ranking.html` showed `#ranking-backend-state` rendered as `Backend no disponible`, confirming the intended offline fallback path.
Known limitation:
- Live successful ranking calls could not be verified through a running backend during this task because the backend was not available over HTTP in the environment.
## Recommended Follow-up
- Add a dedicated ranking frontend regression script so `ranking.html` state coverage is validated alongside existing historical and stats checks.

View File

@@ -1,93 +0,0 @@
---
id: TASK-181-add-global-ranking-backend-support
title: Add global ranking backend support
status: pending
type: backend
team: Backend Senior
supporting_teams:
- Arquitecto Python
- Arquitecto de Base de Datos
roadmap_item: foundation
priority: high
---
# TASK-181-add-global-ranking-backend-support - Add global ranking backend support
## Goal
Implement backend API support for the new global ranking page using existing RCON historical leaderboard logic and annual snapshot readers, without changing public Stats endpoints.
## Context
The backend should expose a dedicated global ranking endpoint that supports weekly/monthly and annual modes, with initial metric support set to `kills` and clear behavior for unsupported inputs and limits.
## Steps
1. Read the listed files first.
2. Reuse leaderboard query modules for weekly/monthly ranking reads.
3. Reuse annual snapshot reader for annual ranking reads, with no recalculation per request.
4. Add route-level validation for timeframe/metric/limit/year behavior.
5. Keep payload shapes compatible with future frontend rendering and existing frontend patterns.
6. Document any optional follow-up if additional metrics are delayed beyond V1.
## Files to Read First
- AGENTS.md
- ai/repo-context.md
- ai/architecture-index.md
- docs/global-ranking-page-plan.md
- docs/stats-section-functional-plan.md
- docs/annual-ranking-snapshot-runbook.md
- backend/app/routes.py
- backend/app/payloads.py
- backend/app/rcon_historical_leaderboards.py
- backend/app/rcon_annual_rankings.py
- scripts/run-stats-validation.ps1
- scripts/run-integration-tests.ps1
## Expected Files to Modify
- backend/app/routes.py
- backend/app/payloads.py, if required for response normalization
- backend/app/rcon_historical_leaderboards.py, only if small reusable exposure is needed
- backend/app/rcon_annual_rankings.py, only if response shape/read behavior adaptation is needed without changing core logic
- scripts/run-stats-validation.ps1 or a new script to cover global ranking
- ai/tasks/done/TASK-181-add-global-ranking-backend-support.md
## Constraints
- Do not create new architecture.
- Do not add large migrations.
- Do not recalculate annual ranking on each public request.
- Do not re-enable Elo/MMR.
- Do not reintroduce Comunidad Hispana #03.
- Do not use public scoreboard as primary source while RCON coverage exists.
- Preserve existing Stats endpoint compatibility.
- Avoid frontend changes unless strictly required for backend documentation-only validation.
- Keep changes scoped and verifiable.
## Outcome
- Endpoint created and documented.
- Final payload contract captured and aligned with Ranking page requirements.
- Source flow documented by timeframe:
- weekly/monthly from RCON materialized data
- annual from annual snapshot records
- Validation outputs and known limits recorded.
- Follow-up recommendation for next task: frontend page implementation.
## Validation
- Run `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1`.
- If endpoint-specific checks are added, run them in this task.
- Validate endpoint cases:
- weekly, `metric=kills`, `limit=20`
- monthly, `metric=kills`, `limit=20`
- annual, `metric=kills`, `limit=20`
- annual path with year if the implementation requires it
- low limit, for example `3`
- high invalid limit
- unsupported metric
- unsupported timeframe
- Run `git diff --name-only` within the scoped files.

View File

@@ -1,97 +0,0 @@
---
id: TASK-182-add-global-ranking-frontend-page
title: Add global ranking frontend page
status: pending
type: frontend
team: Frontend Senior
supporting_teams:
- Experto en interfaz
- Disenador grafico
- Backend Senior
roadmap_item: foundation
priority: high
---
# TASK-182-add-global-ranking-frontend-page - Add global ranking frontend page
## Goal
Create a dedicated Ranking page/section that displays global top lists by timeframe, server, metric, and limit, consuming the backend support built in TASK-181.
## Context
This page must be separated from Stats and focused on list-level ranking discovery (global leaders), while clearly linking to Stats for individual player lookup.
## Steps
1. Read the listed files first.
2. Add `frontend/ranking.html` with controls for timeframe, server, metric, and limit.
3. Implement `frontend/assets/js/ranking.js` to call the global ranking endpoint and render rows.
4. Update shared styles in `frontend/assets/css/styles.css` as needed, preserving the project visual direction.
5. Wire ranking access from existing navigation (`frontend/index.html`) only if needed.
6. Add a minimal cross-link to Stats if useful and safe.
7. Document covered UI states and fallback behavior when backend is unavailable.
## Files to Read First
- AGENTS.md
- ai/repo-context.md
- ai/architecture-index.md
- docs/global-ranking-page-plan.md
- frontend/index.html
- frontend/historico.html
- frontend/stats.html
- frontend/assets/js/stats.js
- frontend/assets/css/styles.css
- backend/app/routes.py
- ai/tasks/done/TASK-181-add-global-ranking-backend-support.md
## Expected Files to Modify
- frontend/ranking.html
- frontend/assets/js/ranking.js
- frontend/assets/css/styles.css
- frontend/index.html, only if navigation needs update
- frontend/stats.html, only if minimal cross-link is required
- scripts/run-stats-validation.ps1 or new ranking validation script
- ai/tasks/done/TASK-182-add-global-ranking-frontend-page.md
## Constraints
- No backend modifications.
- No new endpoints.
- No database migrations.
- No Elo/MMR reactivation.
- No reintroduction of Comunidad Hispana #03.
- No frameworks; continue with vanilla HTML/CSS/JS.
- Preserve military/Vietnam/tactical/sober visual identity.
- Do not regress Stats behavior.
- Avoid duplicating complex Stats logic; reuse patterns where practical.
## Outcome
- Global ranking page created and consumable.
- Endpoint consumed and documented.
- Validation of UI states:
- loading
- backend offline
- no data
- annual snapshot missing
- unsupported metric
- controlled error
- Validation list and known limitations recorded.
- Recommended follow-up tasks (if any).
## Validation
- Run `node --check frontend/assets/js/ranking.js`.
- Run `node --check frontend/assets/js/stats.js`.
- Run `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1`.
- Serve frontend with `python -m http.server` and verify HTTP 200 for:
- `ranking.html`
- `assets/js/ranking.js`
- `stats.html`
- If backend is available, validate ranking calls against live endpoint.
- If backend is unavailable, validate UI offline state.
- Run `git diff --name-only` and verify scoped changes.

View File

@@ -50,6 +50,7 @@ from .historical_storage import (
) )
from .rcon_historical_read_model import get_rcon_historical_match_detail from .rcon_historical_read_model import get_rcon_historical_match_detail
from .rcon_annual_rankings import get_annual_ranking_snapshot from .rcon_annual_rankings import get_annual_ranking_snapshot
from .rcon_historical_leaderboards import list_rcon_materialized_leaderboard
from .rcon_historical_player_stats import search_rcon_materialized_players from .rcon_historical_player_stats import search_rcon_materialized_players
from .rcon_historical_player_stats import get_rcon_materialized_player_stats from .rcon_historical_player_stats import get_rcon_materialized_player_stats
from .normalizers import normalize_map_name from .normalizers import normalize_map_name
@@ -480,6 +481,41 @@ def _to_iso_or_none(value: object) -> str | None:
return parsed.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") return parsed.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
def _normalize_public_server_id(server_id: str | None) -> str:
normalized = str(server_id or "").strip().lower()
if not normalized or normalized == "all":
return ALL_SERVERS_SLUG
return str(server_id).strip()
def _serialize_public_server_id(server_id: object) -> str:
normalized = str(server_id or "").strip()
if not normalized or normalized == ALL_SERVERS_SLUG:
return "all"
return normalized
def _normalize_global_ranking_items(items: object) -> list[dict[str, object]]:
normalized_items: list[dict[str, object]] = []
for item in items if isinstance(items, list) else []:
if not isinstance(item, dict):
continue
normalized_items.append(
{
"ranking_position": int(item.get("ranking_position") or 0),
"player_id": item.get("player_id"),
"player_name": item.get("player_name"),
"metric_value": int(item.get("metric_value") or 0),
"matches_considered": int(item.get("matches_considered") or 0),
"kills": int(item.get("kills") or 0),
"deaths": int(item.get("deaths") or 0),
"teamkills": int(item.get("teamkills") or 0),
"kd_ratio": float(item.get("kd_ratio") or 0.0),
}
)
return normalized_items
def _source_when_present(*values: object, source: str) -> str | None: def _source_when_present(*values: object, source: str) -> str | None:
return source if any(value is not None for value in values) else None return source if any(value is not None for value in values) else None
@@ -713,6 +749,95 @@ def build_annual_ranking_snapshot_payload(
} }
def build_global_ranking_payload(
*,
timeframe: str = "weekly",
server_id: str | None = None,
metric: str = "kills",
limit: int = 20,
year: int | None = None,
) -> dict[str, object]:
"""Return the dedicated Ranking page payload without changing Stats contracts."""
normalized_timeframe = str(timeframe or "weekly").strip().lower()
normalized_server_id = _normalize_public_server_id(server_id)
if normalized_timeframe == "annual":
if year is None:
raise ValueError("year is required when timeframe=annual")
result = get_annual_ranking_snapshot(
year=year,
server_key=normalized_server_id,
metric=metric,
limit=limit,
)
return {
"status": "ok",
"data": {
"page_kind": "global-ranking",
"title": "Ranking global anual",
"context": "global-ranking-annual",
"timeframe": "annual",
"server_id": _serialize_public_server_id(result.get("server_id")),
"metric": result.get("metric"),
"limit": int(result.get("limit") or 0),
"requested_limit": int(result.get("requested_limit") or 0),
"effective_limit": int(result.get("effective_limit") or 0),
"year": int(result.get("year") or year),
"window_start": result.get("window_start"),
"window_end": result.get("window_end"),
"window_kind": "annual-snapshot",
"window_label": "Anual",
"snapshot_status": result.get("snapshot_status"),
"snapshot_limit": result.get("snapshot_limit"),
"item_count": int(result.get("item_count") or 0),
"source_matches_count": int(result.get("source_matches_count") or 0),
"source": {
"primary_source": "rcon",
"read_model": "rcon-annual-ranking-snapshot",
"generated_at": result.get("generated_at"),
"freshness": (
"snapshot" if result.get("snapshot_status") == "ready" else "missing"
),
},
"items": _normalize_global_ranking_items(result.get("items")),
},
}
result = list_rcon_materialized_leaderboard(
server_key=normalized_server_id,
timeframe=normalized_timeframe,
metric=metric,
limit=limit,
)
return {
"status": "ok",
"data": {
"page_kind": "global-ranking",
"title": "Ranking global",
"context": f"global-ranking-{normalized_timeframe}",
"timeframe": normalized_timeframe,
"server_id": _serialize_public_server_id(result.get("server_key")),
"metric": result.get("metric"),
"limit": int(result.get("limit") or limit),
"requested_limit": int(limit),
"effective_limit": int(result.get("limit") or limit),
"window_start": result.get("window_start"),
"window_end": result.get("window_end"),
"window_kind": result.get("window_kind"),
"window_label": result.get("window_label"),
"selection_reason": result.get("selection_reason"),
"snapshot_status": "ready",
"source": {
"primary_source": "rcon",
"read_model": "rcon-materialized-admin-log-leaderboard",
"generated_at": _utc_timestamp_now(),
"freshness": "runtime",
},
"items": _normalize_global_ranking_items(result.get("items")),
},
}
def build_weekly_leaderboard_payload( def build_weekly_leaderboard_payload(
*, *,
limit: int = 10, limit: int = 10,

View File

@@ -8,6 +8,7 @@ from urllib.parse import parse_qs, urlparse
from .config import get_historical_data_source_kind from .config import get_historical_data_source_kind
from .payloads import ( from .payloads import (
build_global_ranking_payload,
build_stats_player_profile_payload, build_stats_player_profile_payload,
build_community_payload, build_community_payload,
build_current_match_kill_feed_payload, build_current_match_kill_feed_payload,
@@ -111,6 +112,38 @@ def resolve_get_payload(path: str) -> tuple[HTTPStatus | None, dict[str, object]
except ValueError as error: except ValueError as error:
return HTTPStatus.BAD_REQUEST, build_error_payload(str(error)) return HTTPStatus.BAD_REQUEST, build_error_payload(str(error))
if parsed.path == "/api/ranking":
params = parse_qs(parsed.query)
timeframe = params.get("timeframe", ["weekly"])[0]
if timeframe not in {"weekly", "monthly", "annual"}:
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid timeframe parameter")
metric = params.get("metric", ["kills"])[0]
if metric != "kills":
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid metric parameter")
limit = _parse_limit(parsed.query)
if limit is None:
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid limit parameter")
server_id = params.get("server_id", [None])[0]
if server_id is None:
server_id = params.get("server", [None])[0]
if not _is_supported_ranking_server_id(server_id):
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid server_id parameter")
year = None
if timeframe == "annual":
year = _parse_required_year(parsed.query)
if year is None:
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid year parameter")
try:
return HTTPStatus.OK, build_global_ranking_payload(
timeframe=timeframe,
server_id=server_id,
metric=metric,
limit=limit,
year=year,
)
except ValueError as error:
return HTTPStatus.BAD_REQUEST, build_error_payload(str(error))
if parsed.path == "/api/current-match": if parsed.path == "/api/current-match":
server_slug = parse_qs(parsed.query).get("server", [None])[0] server_slug = parse_qs(parsed.query).get("server", [None])[0]
if not server_slug: if not server_slug:
@@ -471,8 +504,28 @@ def _parse_year(query: str) -> int | None:
return year return year
def _parse_required_year(query: str) -> int | None:
params = parse_qs(query)
if "year" not in params:
return None
return _parse_year(query)
def _parse_limit_with_default(query: str, default: int = 20) -> int | None: def _parse_limit_with_default(query: str, default: int = 20) -> int | None:
params = parse_qs(query) params = parse_qs(query)
if "limit" not in params: if "limit" not in params:
return default return default
return _parse_limit(query) return _parse_limit(query)
def _is_supported_ranking_server_id(server_id: str | None) -> bool:
if server_id is None:
return True
normalized = str(server_id).strip().lower()
return normalized in {
"",
"all",
"all-servers",
"comunidad-hispana-01",
"comunidad-hispana-02",
}

View File

@@ -0,0 +1,261 @@
# Global Ranking Page Plan
## Objective
Define the V1 functional contract for a dedicated `Ranking` page that exposes public top lists across HLL Vietnam community servers without merging this flow into `Stats`.
## Why Ranking Is Separate From Stats
`Stats` and `Ranking` solve different user jobs:
- `Stats` is player-centric: search one player, open one profile, inspect personal performance.
- `Ranking` is leaderboard-centric: open a public table, switch filters, compare top players.
They can safely share:
- the same sober tactical visual system already used by `stats.html` and `historico.html`
- the same backend health/offline messaging pattern
- the same table/card rendering conventions for loading, empty and error states
They must remain separate in navigation and contracts:
- `Stats` keeps player search and personal summary endpoints.
- `Ranking` gets its own page and dedicated ranking endpoint.
- `Ranking` may link to `Stats` for player lookup, but it does not embed the player search workflow as a primary interaction.
## Current Foundation
- Weekly and monthly historical leaderboard reads already exist in `backend/app/rcon_historical_leaderboards.py`.
- Annual ranking reads already exist through snapshot storage in `backend/app/rcon_annual_rankings.py`.
- Historical ranking remains RCON-first.
- Public scoreboard remains fallback or enrichment only and must not become the primary read path while RCON coverage exists.
## V1 Scope
The first Ranking page supports:
- public top-player lists
- timeframe filter: `weekly`, `monthly`, `annual`
- server scope filter: `all`, `comunidad-hispana-01`, `comunidad-hispana-02`
- metric filter: V1 committed to `kills`
- limit filter: default `20`, with smaller values allowed
- filter changes without manual page reload where feasible
- clear differentiation between `ready`, `missing`, `empty`, `offline` and request-error states
## V1 Non-goals
- Elo/MMR
- authentication
- private or expanded player profiles
- advanced charts
- weapon or map breakdowns
- large schema changes
- annual recalculation on public request
- reintroducing Comunidad Hispana #03
## User Flow
1. User opens `Ranking`.
2. Page checks backend availability.
3. User chooses timeframe.
4. User chooses server scope.
5. User chooses metric.
6. User chooses limit if needed.
7. Frontend requests ranking data and updates the list in place.
8. User can switch filters again without manual reload.
9. If the user wants one-player analysis, the page offers a small link to `Stats`.
## Data Source Policy
### Weekly and monthly
- Source: materialized RCON/AdminLog leaderboard read model.
- Reader: existing weekly/monthly selection logic in `select_leaderboard_window(...)`.
- Ordering: metric desc, `matches_considered` desc, `player_name` asc.
- V1 metric commitment: `kills`.
### Annual
- Source: persisted annual ranking snapshots.
- Reader: existing annual snapshot loader.
- No annual recomputation during public requests.
- Missing annual data must surface as a controlled `snapshot_status="missing"` state.
## API Contract Direction
### Dedicated Ranking endpoint
```http
GET /api/ranking?timeframe=weekly|monthly|annual&server_id=<server-or-all>&metric=kills&limit=20&year=<year-when-annual>
```
Purpose:
- give Ranking its own public contract without changing Stats endpoints or mixing player-profile concerns into ranking reads
Query rules:
- `timeframe` is optional and defaults to `weekly`
- `server_id` is optional and defaults to `all`
- `metric` is optional and defaults to `kills`
- `limit` is optional and defaults to `20`
- `year` is required only when `timeframe=annual`
Validation rules:
- allowed `timeframe`: `weekly`, `monthly`, `annual`
- V1 allowed `metric`: `kills`
- allowed `limit`: `1..100`
- `year` must be a positive integer when annual is requested
- annual requests ignore weekly/monthly runtime window policy and read snapshots only
## Response Contract
```json
{
"status": "ok",
"data": {
"page_kind": "global-ranking",
"timeframe": "monthly",
"server_id": "all",
"metric": "kills",
"limit": 20,
"requested_limit": 20,
"window_start": "2026-06-01T00:00:00Z",
"window_end": "2026-06-08T18:00:00Z",
"window_kind": "current-month",
"window_label": "Mes actual",
"source": {
"primary_source": "rcon",
"read_model": "rcon-materialized-admin-log-leaderboard",
"generated_at": "2026-06-08T18:00:00Z",
"freshness": "runtime"
},
"snapshot_status": "ready",
"items": [
{
"ranking_position": 1,
"player_id": "76561198000000000",
"player_name": "Rambo",
"metric_value": 421,
"matches_considered": 14,
"kills": 421,
"deaths": 280,
"teamkills": 3,
"kd_ratio": 1.5
}
]
}
}
```
## Field Rules
Common top-level fields:
- `timeframe`: `weekly`, `monthly`, `annual`
- `server_id`: `all` or one supported active server id
- `metric`: `kills` in V1
- `limit`: effective served limit
- `requested_limit`: requested client limit
- `snapshot_status`: always present for frontend state handling
- `items`: always an array
Required item fields:
- `ranking_position`
- `player_id`
- `player_name`
- `metric_value`
- `matches_considered`
- `kills`
- `deaths`
- `teamkills`
- `kd_ratio`
Weekly/monthly-only metadata:
- `window_start`
- `window_end`
- `window_kind`
- `window_label`
- `selection_reason`
Annual-only metadata:
- `year`
- `generated_at`
- `snapshot_limit`
- `effective_limit`
- `item_count`
## Timeframe-specific expectations
### Weekly
- Use the same weekly fallback rule already defined by the leaderboard read model.
- If current-week sample is insufficient, backend may serve previous-week data.
- Frontend must not reinterpret the window; it should display returned window metadata.
### Monthly
- Use the same current-month vs previous-month rule already defined by the leaderboard read model.
- Frontend must display the returned monthly window metadata exactly as served.
### Annual
- Read only from annual snapshots.
- `snapshot_status="ready"` with `items=[]` is a valid empty-ready state.
- `snapshot_status="missing"` is not a backend crash and must be rendered distinctly.
## UI State Contract
The Ranking page must support these states explicitly:
- `loading`: request in flight
- `backend offline`: `/health` unavailable or ranking request unreachable
- `no data`: successful response with `items=[]` in weekly/monthly
- `annual snapshot missing`: annual response with `snapshot_status="missing"`
- `unsupported metric`: client requested unsupported metric or backend returns 400 metric validation error
- `controlled error`: valid HTTP response with request validation error or unexpected backend failure
Expected rendering behavior:
- loading keeps previous table hidden or visually muted
- backend offline shows a clear retry-safe message
- no data keeps filters visible and explains that there are no rows for the active scope
- annual snapshot missing explains that the annual snapshot has not been generated yet
- unsupported metric does not silently downgrade to another metric
- controlled error does not break page navigation or filter controls
## Backend Behavior Requirements
- Preserve existing Stats endpoint compatibility.
- Do not expose public-scoreboard as the normal primary source for Ranking.
- Do not expand annual generation logic inside request handling.
- Keep server scope limited to active supported scopes: `all`, `comunidad-hispana-01`, `comunidad-hispana-02`.
- Do not surface Comunidad Hispana #03 in defaults, options or backend read scope.
## Frontend Integration Guidance
Recommended page split:
- `stats.html`: personal lookup and personal ranking context
- `ranking.html`: public top lists
Recommended safe reuse:
- backend availability chip pattern from `stats.js`
- existing tactical panel and card styling from `styles.css`
- simple select/button driven filtering similar to `historico.html`
Recommended minimal cross-links:
- `Ranking` page links to `Stats` with copy like "Buscar jugador"
- `Stats` page may link back to `Ranking` with copy like "Ver ranking global"
## Follow-up Task Suggestions
- Implement `GET /api/ranking` by adapting existing weekly/monthly leaderboard reads and annual snapshot reads.
- Build `frontend/ranking.html` and `frontend/assets/js/ranking.js` against this contract.
- Add a small regression script for Ranking endpoint and frontend state validation.

View File

@@ -985,6 +985,11 @@ h2 {
color: #e2d7a9; color: #e2d7a9;
} }
.stats-state--ready {
border-color: rgba(183, 201, 125, 0.32);
color: var(--accent-strong);
}
.stats-result-list { .stats-result-list {
display: grid; display: grid;
gap: 10px; gap: 10px;
@@ -1225,6 +1230,121 @@ h2 {
width: fit-content; width: fit-content;
} }
.ranking-form {
display: grid;
gap: 16px;
margin-top: 20px;
}
.ranking-form__grid {
display: grid;
gap: 12px;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr));
}
.ranking-form__actions {
margin-top: 0;
}
.ranking-select {
width: 100%;
min-height: 52px;
margin-top: 8px;
padding: 0 16px;
border: 1px solid rgba(210, 182, 118, 0.3);
border-radius: 12px;
background: linear-gradient(180deg, rgba(19, 24, 16, 0.96), rgba(10, 13, 9, 0.98));
color: var(--text);
font-size: 1rem;
outline: none;
}
.ranking-select:focus-visible {
border-color: rgba(210, 182, 118, 0.6);
box-shadow: 0 0 0 2px rgba(210, 182, 118, 0.2);
}
.ranking-meta {
display: grid;
gap: 12px;
margin-bottom: 16px;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr));
}
.ranking-meta-card {
margin: 0;
padding: 14px 16px;
border: 1px solid rgba(159, 168, 141, 0.2);
border-radius: 16px;
background:
linear-gradient(180deg, rgba(28, 34, 25, 0.88), rgba(12, 15, 11, 0.96));
}
.ranking-meta-card p,
.ranking-meta-card strong {
margin: 0;
}
.ranking-meta-card p {
margin-bottom: 8px;
color: var(--muted);
font-size: 0.74rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.ranking-table-shell {
overflow-x: auto;
}
.ranking-table {
width: 100%;
border-collapse: collapse;
border-spacing: 0;
border: 1px solid rgba(159, 168, 141, 0.18);
border-radius: 18px;
overflow: hidden;
background: linear-gradient(180deg, rgba(19, 24, 16, 0.88), rgba(10, 13, 9, 0.96));
}
.ranking-table th,
.ranking-table td {
padding: 14px 16px;
text-align: left;
border-bottom: 1px solid rgba(159, 168, 141, 0.12);
}
.ranking-table thead th {
color: var(--accent-strong);
font-size: 0.76rem;
letter-spacing: 0.08em;
text-transform: uppercase;
background: rgba(14, 18, 12, 0.96);
}
.ranking-table tbody tr:hover {
background: rgba(183, 201, 125, 0.05);
}
.ranking-player {
display: grid;
gap: 4px;
}
.ranking-player span {
color: var(--muted);
font-size: 0.82rem;
}
.ranking-empty {
padding: 18px 20px;
border: 1px dashed rgba(210, 182, 118, 0.24);
border-radius: 18px;
background: linear-gradient(180deg, rgba(31, 28, 22, 0.82), rgba(14, 12, 9, 0.92));
color: var(--text-soft);
line-height: 1.7;
}
.server-action-link--disabled { .server-action-link--disabled {
border-color: rgba(159, 168, 141, 0.18); border-color: rgba(159, 168, 141, 0.18);
background: linear-gradient(180deg, rgba(42, 46, 39, 0.5), rgba(19, 22, 17, 0.7)); background: linear-gradient(180deg, rgba(42, 46, 39, 0.5), rgba(19, 22, 17, 0.7));
@@ -1296,6 +1416,16 @@ h2 {
width: 100%; width: 100%;
} }
.ranking-form__actions {
width: 100%;
justify-content: center;
}
.ranking-table th,
.ranking-table td {
padding: 12px 10px;
}
.logo-frame { .logo-frame {
min-height: 180px; min-height: 180px;
padding: 16px 18px; padding: 16px 18px;

View File

@@ -0,0 +1,391 @@
document.addEventListener("DOMContentLoaded", () => {
const backendBaseUrl = document.body.dataset.backendBaseUrl || "http://127.0.0.1:8000";
const form = document.getElementById("ranking-form");
const timeframeSelect = document.getElementById("ranking-timeframe");
const serverSelect = document.getElementById("ranking-server");
const metricSelect = document.getElementById("ranking-metric");
const limitSelect = document.getElementById("ranking-limit");
const yearWrap = document.getElementById("ranking-year-wrap");
const yearInput = document.getElementById("ranking-year");
const backendStateNode = document.getElementById("ranking-backend-state");
const stateNode = document.getElementById("ranking-state");
const titleNode = document.getElementById("ranking-title");
const metaNode = document.getElementById("ranking-meta");
const tableNode = document.getElementById("ranking-table");
const tableBodyNode = document.getElementById("ranking-table-body");
const emptyNode = document.getElementById("ranking-empty");
const currentYear = new Date().getUTCFullYear();
let isBackendOnline = false;
if (yearInput) {
yearInput.value = String(currentYear);
}
toggleYearField();
setBackendState("Comprobando disponibilidad del backend", false);
setRankingState("neutral", "Esperando filtros para cargar el ranking global.");
clearRankingSurface();
refreshBackendHealth();
if (timeframeSelect) {
timeframeSelect.addEventListener("change", () => {
toggleYearField();
void loadRanking();
});
}
[serverSelect, metricSelect, limitSelect].forEach((node) => {
if (!node) {
return;
}
node.addEventListener("change", () => {
void loadRanking();
});
});
if (form) {
form.addEventListener("submit", (event) => {
event.preventDefault();
void loadRanking();
});
}
function setBackendState(label, online) {
isBackendOnline = online;
if (!backendStateNode) {
return;
}
backendStateNode.textContent = label;
backendStateNode.classList.toggle("status-chip--ok", online);
backendStateNode.classList.toggle("status-chip--fallback", !online);
}
function setRankingState(state, message) {
if (!stateNode) {
return;
}
stateNode.textContent = message;
stateNode.className = `stats-state stats-state--${state}`;
}
function toggleYearField() {
const isAnnual = timeframeSelect?.value === "annual";
if (yearWrap) {
yearWrap.hidden = !isAnnual;
}
if (yearInput) {
yearInput.disabled = !isAnnual;
}
}
function clearRankingSurface() {
if (metaNode) {
metaNode.innerHTML = "";
}
if (tableBodyNode) {
tableBodyNode.innerHTML = "";
}
if (tableNode) {
tableNode.hidden = true;
}
if (emptyNode) {
emptyNode.hidden = true;
emptyNode.textContent = "";
}
}
function renderEmptyState(message) {
clearRankingSurface();
if (!emptyNode) {
return;
}
emptyNode.hidden = false;
emptyNode.textContent = message;
}
async function refreshBackendHealth() {
try {
const response = await fetch(`${backendBaseUrl}/health`);
if (!response.ok) {
throw new Error(`Health request failed with ${response.status}`);
}
const payload = await response.json();
if (!payload || payload.status !== "ok") {
throw new Error("Unexpected health payload");
}
setBackendState("Backend operativo", true);
setRankingState("neutral", "Backend disponible. Ajusta filtros o usa la lectura inicial.");
void loadRanking();
} catch (error) {
console.warn("Ranking health check failed", error);
setBackendState("Backend no disponible", false);
setRankingState("error", "Backend no disponible. El ranking queda en estado offline.");
renderEmptyState(
"No fue posible contactar el backend. Cuando vuelva a estar disponible podras consultar semanal, mensual o anual.",
);
}
}
async function loadRanking() {
const timeframe = String(timeframeSelect?.value || "weekly");
const serverId = String(serverSelect?.value || "all");
const metric = String(metricSelect?.value || "kills");
const limit = String(limitSelect?.value || "20");
if (!isBackendOnline) {
setRankingState("error", "Backend no disponible. El ranking queda en estado offline.");
renderEmptyState(
"No fue posible contactar el backend. Reintenta cuando el servicio vuelva a estar operativo.",
);
return;
}
let year = null;
if (timeframe === "annual") {
year = Number.parseInt(String(yearInput?.value || "").trim(), 10);
if (!Number.isFinite(year) || year <= 0) {
setRankingState("error", "El ano solicitado no es valido.");
renderEmptyState("Corrige el ano y vuelve a consultar el ranking anual.");
return;
}
}
setRankingState("loading", "Cargando ranking global...");
clearRankingSurface();
try {
const searchParams = new URLSearchParams({
timeframe,
server_id: serverId,
metric,
limit,
});
if (timeframe === "annual" && year !== null) {
searchParams.set("year", String(year));
}
const response = await fetch(`${backendBaseUrl}/api/ranking?${searchParams.toString()}`);
if (!response.ok) {
const errorPayload = await safeParseJson(response);
const errorMessage = String(errorPayload?.message || errorPayload?.detail || "").toLowerCase();
handleRequestError(response.status, errorMessage, timeframe);
return;
}
const payload = await response.json();
if (!payload || payload.status !== "ok") {
throw new Error(payload?.message || "Respuesta de ranking invalida");
}
renderRanking(payload.data || {});
} catch (error) {
console.warn("Ranking request failed", error);
setBackendState("Backend no disponible", false);
setRankingState("error", "Error controlado al cargar el ranking.");
renderEmptyState(
"La lectura del ranking fallo en este intento. Revisa el backend o actualiza la pagina.",
);
}
}
function handleRequestError(statusCode, errorMessage, timeframe) {
const normalizedMessage = String(errorMessage || "");
if (statusCode === 400 && normalizedMessage.includes("metric")) {
setRankingState("warning", "La metrica solicitada no esta soportada en V1.");
renderEmptyState(
"Solo la metrica kills esta disponible en esta primera version del ranking global.",
);
return;
}
if (statusCode === 400 && normalizedMessage.includes("year")) {
setRankingState("warning", "El ranking anual requiere un ano valido.");
renderEmptyState("Define un ano valido para consultar la lectura anual.");
return;
}
if (statusCode === 400 && normalizedMessage.includes("timeframe")) {
setRankingState("warning", "El periodo solicitado no esta soportado.");
renderEmptyState("Usa una ventana semanal, mensual o anual.");
return;
}
if (timeframe === "annual") {
setRankingState("error", "Error controlado al cargar el ranking anual.");
} else {
setRankingState("error", "Error controlado al cargar el ranking.");
}
renderEmptyState(
"La consulta devolvio un error controlado. Ajusta los filtros y vuelve a intentarlo.",
);
}
function renderRanking(data) {
const items = Array.isArray(data.items) ? data.items : [];
const timeframe = String(data.timeframe || "weekly");
const serverId = String(data.server_id || "all");
const metric = String(data.metric || "kills");
const snapshotStatus = String(data.snapshot_status || "").toLowerCase();
if (titleNode) {
titleNode.textContent =
timeframe === "annual" ? "Top anual activo" : "Tabla activa del alcance seleccionado";
}
if (metaNode) {
metaNode.innerHTML = buildMetaMarkup(data);
}
if (timeframe === "annual" && snapshotStatus === "missing") {
setRankingState("warning", "El snapshot anual solicitado aun no fue generado.");
renderEmptyState(
"No existe snapshot anual para el ano y servidor elegidos. Este estado es informativo y no implica caida del backend.",
);
return;
}
if (!items.length) {
setRankingState(
"neutral",
timeframe === "annual"
? "Snapshot anual listo pero sin filas visibles para este filtro."
: "No hay datos visibles para el periodo y servidor seleccionado.",
);
renderEmptyState(
timeframe === "annual"
? "La lectura anual existe pero no devolvio filas para este filtro."
: "No se encontraron jugadores con actividad suficiente en esta ventana.",
);
return;
}
setRankingState(
"ready",
`Ranking ${labelForTimeframe(timeframe)} listo para ${labelForServer(serverId)} por ${metric}.`,
);
if (tableBodyNode) {
tableBodyNode.innerHTML = items.map(renderRow).join("");
}
if (tableNode) {
tableNode.hidden = false;
}
if (emptyNode) {
emptyNode.hidden = true;
}
}
function buildMetaMarkup(data) {
const source = data.source && typeof data.source === "object" ? data.source : {};
const parts = [
`<article class="ranking-meta-card"><p>Servidor</p><strong>${escapeHtml(labelForServer(data.server_id))}</strong></article>`,
`<article class="ranking-meta-card"><p>Ventana</p><strong>${escapeHtml(labelForWindow(data))}</strong></article>`,
`<article class="ranking-meta-card"><p>Fuente</p><strong>${escapeHtml(String(source.read_model || "No disponible"))}</strong></article>`,
`<article class="ranking-meta-card"><p>Actualizado</p><strong>${escapeHtml(formatDateTime(source.generated_at))}</strong></article>`,
];
if (String(data.timeframe || "") === "annual") {
parts.push(
`<article class="ranking-meta-card"><p>Snapshot</p><strong>${escapeHtml(String(data.snapshot_status || "missing"))}</strong></article>`,
);
}
return parts.join("");
}
function renderRow(item) {
return `
<tr>
<td>#${safeInt(item.ranking_position, 0)}</td>
<td>
<div class="ranking-player">
<strong>${escapeHtml(String(item.player_name || "Jugador sin nombre"))}</strong>
<span>${escapeHtml(String(item.player_id || "Sin ID"))}</span>
</div>
</td>
<td>${safeInt(item.metric_value, 0)}</td>
<td>${safeInt(item.matches_considered, 0)}</td>
<td>${safeInt(item.deaths, 0)}</td>
<td>${safeInt(item.teamkills, 0)}</td>
<td>${safeDecimal(item.kd_ratio, 2, "0.00")}</td>
</tr>
`;
}
function labelForWindow(data) {
if (String(data.timeframe || "") === "annual") {
return `Ano ${safeInt(data.year, currentYear)}`;
}
return String(data.window_label || data.window_kind || "Ventana activa");
}
function labelForServer(serverId) {
if (serverId === "comunidad-hispana-01") {
return "Comunidad Hispana #01";
}
if (serverId === "comunidad-hispana-02") {
return "Comunidad Hispana #02";
}
return "Todos los servidores";
}
function labelForTimeframe(timeframe) {
if (timeframe === "monthly") {
return "mensual";
}
if (timeframe === "annual") {
return "anual";
}
return "semanal";
}
function safeInt(value, fallback) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
return fallback;
}
return parsed;
}
function safeDecimal(value, maximumFractionDigits, fallback) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
return fallback;
}
return parsed.toLocaleString("es-ES", {
minimumFractionDigits: maximumFractionDigits,
maximumFractionDigits,
});
}
function safeParseJson(response) {
if (!response) {
return null;
}
return response
.json()
.then((parsed) => parsed)
.catch(() => null);
}
function formatDateTime(value) {
if (!value) {
return "No disponible";
}
const parsedDate = new Date(String(value));
if (Number.isNaN(parsedDate.getTime())) {
return "No disponible";
}
return new Intl.DateTimeFormat("es-ES", {
dateStyle: "short",
timeStyle: "short",
}).format(parsedDate);
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
});

View File

@@ -51,6 +51,9 @@
<a class="secondary-button" href="./stats.html"> <a class="secondary-button" href="./stats.html">
Stats Stats
</a> </a>
<a class="secondary-button" href="./ranking.html">
Ranking
</a>
<a class="secondary-button" href="./historico.html"> <a class="secondary-button" href="./historico.html">
Ver historico propio Ver historico propio
</a> </a>

149
frontend/ranking.html Normal file
View File

@@ -0,0 +1,149 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="Ranking global de HLL Vietnam con filtros por periodo, servidor y limite."
/>
<title>Ranking - HLL Vietnam</title>
<link rel="stylesheet" href="./assets/css/styles.css" />
</head>
<body data-backend-base-url="http://127.0.0.1:8000">
<div class="page-shell stats-page-shell">
<header class="hero stats-hero ranking-hero">
<div class="hero__overlay"></div>
<div class="hero__content">
<div class="hero__copy">
<p class="eyebrow eyebrow--section">Seccion Ranking</p>
<h1 class="hero__title">
Ranking
<span class="hero__title-accent">Global</span>
</h1>
<p class="hero__text">
Consulta los lideres publicos por kills con lectura RCON-first, cambia
periodo y servidor sin salir de la pagina, y usa Stats solo cuando
necesites revisar el rendimiento de un jugador concreto.
</p>
<p class="status-chip status-chip--fallback" id="ranking-backend-state">
Comprobando disponibilidad del backend
</p>
<div class="hero__actions stats__hero-actions">
<a class="secondary-button secondary-button--ghost" href="./index.html">
Volver al inicio
</a>
<a class="secondary-button secondary-button--compact" href="./stats.html">
Stats
</a>
<a class="secondary-button secondary-button--compact" href="./historico.html">
Historico
</a>
</div>
</div>
</div>
</header>
<main class="content stats-content">
<section class="panel">
<div class="panel__shell">
<div class="panel__header">
<p class="eyebrow eyebrow--section">Filtros de lectura</p>
<h2>Top publico por ventana</h2>
</div>
<p class="panel__intro panel__intro--tight">
El ranking expone solo la lectura publica de lideres. Para busqueda
individual usa la seccion Stats.
</p>
<form class="ranking-form" id="ranking-form">
<div class="ranking-form__grid">
<label class="stats-search-form__label" for="ranking-timeframe">
Periodo
<select class="ranking-select" id="ranking-timeframe" name="timeframe">
<option value="weekly">Semanal</option>
<option value="monthly">Mensual</option>
<option value="annual">Anual</option>
</select>
</label>
<label class="stats-search-form__label" for="ranking-server">
Servidor
<select class="ranking-select" id="ranking-server" name="server_id">
<option value="all">Todos</option>
<option value="comunidad-hispana-01">Comunidad Hispana #01</option>
<option value="comunidad-hispana-02">Comunidad Hispana #02</option>
</select>
</label>
<label class="stats-search-form__label" for="ranking-metric">
Metrica
<select class="ranking-select" id="ranking-metric" name="metric">
<option value="kills">Kills</option>
</select>
</label>
<label class="stats-search-form__label" for="ranking-limit">
Limite
<select class="ranking-select" id="ranking-limit" name="limit">
<option value="10">Top 10</option>
<option value="20" selected>Top 20</option>
<option value="30">Top 30</option>
</select>
</label>
<label class="stats-search-form__label" for="ranking-year" id="ranking-year-wrap" hidden>
Ano
<input
class="stats-search-input"
id="ranking-year"
name="year"
type="number"
min="1"
step="1"
inputmode="numeric"
aria-describedby="ranking-state"
/>
</label>
</div>
<div class="hero__actions ranking-form__actions">
<button class="discord-button" type="submit">Actualizar ranking</button>
<a class="secondary-button secondary-button--compact" href="./stats.html">
Buscar jugador en Stats
</a>
</div>
</form>
<p class="stats-state stats-state--neutral" id="ranking-state" role="status" aria-live="polite">
Esperando filtros para cargar el ranking global.
</p>
</div>
</section>
<section class="panel">
<div class="panel__shell">
<div class="panel__header">
<p class="eyebrow eyebrow--section">Resultados</p>
<h2 id="ranking-title">Tabla activa</h2>
</div>
<div class="ranking-meta" id="ranking-meta"></div>
<div class="ranking-table-shell">
<table class="historical-table ranking-table" id="ranking-table" hidden>
<thead>
<tr>
<th>Pos.</th>
<th>Jugador</th>
<th>Kills</th>
<th>Partidas</th>
<th>Deaths</th>
<th>Teamkills</th>
<th>K/D</th>
</tr>
</thead>
<tbody id="ranking-table-body"></tbody>
</table>
</div>
<div class="ranking-empty" id="ranking-empty" hidden></div>
</div>
</section>
</main>
</div>
<script src="./assets/js/config.js"></script>
<script src="./assets/js/ranking.js"></script>
</body>
</html>

View File

@@ -201,14 +201,73 @@ require(unsupported_metric_status == 400, "Annual ranking with unsupported metri
missing_year_status, _ = read_payload("/api/stats/rankings/annual?year=2999&server_id=all&metric=kills&limit=20") missing_year_status, _ = read_payload("/api/stats/rankings/annual?year=2999&server_id=all&metric=kills&limit=20")
require(missing_year_status == 200, "Future year annual ranking should still return 200.") require(missing_year_status == 200, "Future year annual ranking should still return 200.")
weekly_ranking_status, weekly_ranking_payload = read_payload(
"/api/ranking?timeframe=weekly&server_id=all&metric=kills&limit=20"
)
require(weekly_ranking_status == 200, "Global ranking weekly route should return 200.")
weekly_ranking_data = weekly_ranking_payload.get("data") or {}
require(weekly_ranking_payload.get("status") == "ok", "Global ranking weekly payload should return ok status.")
require(weekly_ranking_data.get("page_kind") == "global-ranking", "Global ranking should expose page_kind.")
require(weekly_ranking_data.get("timeframe") == "weekly", "Global ranking weekly timeframe should be preserved.")
require(weekly_ranking_data.get("metric") == "kills", "Global ranking weekly metric should be kills.")
require(weekly_ranking_data.get("snapshot_status") == "ready", "Global ranking weekly should expose ready snapshot status.")
require(isinstance(weekly_ranking_data.get("items"), list), "Global ranking weekly items must be list.")
require(isinstance(weekly_ranking_data.get("source"), dict), "Global ranking weekly should expose source metadata.")
monthly_ranking_status, monthly_ranking_payload = read_payload(
"/api/ranking?timeframe=monthly&server_id=comunidad-hispana-01&metric=kills&limit=20"
)
require(monthly_ranking_status == 200, "Global ranking monthly route should return 200.")
monthly_ranking_data = monthly_ranking_payload.get("data") or {}
require(monthly_ranking_data.get("timeframe") == "monthly", "Global ranking monthly timeframe should be preserved.")
require(monthly_ranking_data.get("server_id") == "comunidad-hispana-01", "Global ranking monthly should preserve server_id.")
annual_ranking_status, annual_ranking_payload = read_payload(
f"/api/ranking?timeframe=annual&year={current_year}&server_id=all&metric=kills&limit=20"
)
require(annual_ranking_status == 200, "Global ranking annual route should return 200.")
annual_ranking_data = annual_ranking_payload.get("data") or {}
require(annual_ranking_data.get("timeframe") == "annual", "Global ranking annual timeframe should be preserved.")
require(annual_ranking_data.get("metric") == "kills", "Global ranking annual metric should be kills.")
require(annual_ranking_data.get("snapshot_status") in {"ready", "missing"}, "Global ranking annual snapshot_status should be ready or missing.")
require(isinstance(annual_ranking_data.get("items"), list), "Global ranking annual items must be list.")
low_limit_ranking_status, low_limit_ranking_payload = read_payload(
"/api/ranking?timeframe=weekly&server_id=all&metric=kills&limit=3"
)
require(low_limit_ranking_status == 200, "Global ranking with low limit should return 200.")
require((low_limit_ranking_payload.get("data") or {}).get("limit") == 3, "Global ranking low-limit response should preserve limit 3.")
high_limit_ranking_status, _ = read_payload(
"/api/ranking?timeframe=weekly&server_id=all&metric=kills&limit=101"
)
require(high_limit_ranking_status == 400, "Global ranking with limit=101 should return 400.")
unsupported_metric_ranking_status, _ = read_payload(
"/api/ranking?timeframe=weekly&server_id=all&metric=deaths&limit=20"
)
require(unsupported_metric_ranking_status == 400, "Global ranking with unsupported metric should return 400.")
unsupported_timeframe_ranking_status, _ = read_payload(
"/api/ranking?timeframe=seasonal&server_id=all&metric=kills&limit=20"
)
require(unsupported_timeframe_ranking_status == 400, "Global ranking with unsupported timeframe should return 400.")
missing_year_ranking_status, _ = read_payload(
"/api/ranking?timeframe=annual&server_id=all&metric=kills&limit=20"
)
require(missing_year_ranking_status == 400, "Global ranking annual requests without year should return 400.")
print(json.dumps({ print(json.dumps({
"checked": [ "checked": [
"health", "health",
"stats-player-search", "stats-player-search",
"stats-player-profile", "stats-player-profile",
"stats-annual-ranking", "stats-annual-ranking",
"global-ranking",
], ],
"annual_snapshot_status": annual_data.get("snapshot_status"), "annual_snapshot_status": annual_data.get("snapshot_status"),
"global_ranking_annual_snapshot_status": annual_ranking_data.get("snapshot_status"),
"search_items_count": len(search_data.get("items") or []), "search_items_count": len(search_data.get("items") or []),
"profile_matches_considered": profile_data.get("matches_considered"), "profile_matches_considered": profile_data.get("matches_considered"),
})) }))
@@ -260,6 +319,24 @@ if ($backendAvailable) {
throw "Live annual ranking with unsupported metric should return HTTP 400." throw "Live annual ranking with unsupported metric should return HTTP 400."
} }
$rankingWeeklyPayload = Invoke-RestMethod -Uri "$backendBaseUrl/api/ranking?timeframe=weekly&server_id=all&metric=kills&limit=20" -TimeoutSec 5
if ($rankingWeeklyPayload.status -ne "ok") {
throw "Live global ranking weekly route should return status=ok."
}
$rankingAnnualPayload = Invoke-RestMethod -Uri "$backendBaseUrl/api/ranking?timeframe=annual&year=$currentYear&server_id=all&metric=kills&limit=20" -TimeoutSec 5
if ($rankingAnnualPayload.status -ne "ok") {
throw "Live global ranking annual route should return status=ok."
}
if (-not ($rankingAnnualPayload.data.snapshot_status -in @("ready", "missing"))) {
throw "Live global ranking annual should expose ready/missing snapshot status."
}
$rankingUnsupportedMetricStatus = Get-HttpStatusCode -Url "$backendBaseUrl/api/ranking?timeframe=weekly&server_id=all&metric=deaths&limit=20"
if ($rankingUnsupportedMetricStatus -ne 400) {
throw "Live global ranking with unsupported metric should return HTTP 400."
}
Write-Host "Live HTTP checks passed for Stats endpoints." Write-Host "Live HTTP checks passed for Stats endpoints."
} }