Add global ranking page
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: TASK-180-define-global-ranking-page-contract
|
||||
title: Define global ranking page contract
|
||||
status: pending
|
||||
status: done
|
||||
type: documentation
|
||||
team: Analista
|
||||
supporting_teams:
|
||||
@@ -21,33 +21,33 @@ Define a separate, explicit global ranking page contract that is clearly distinc
|
||||
|
||||
## 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
|
||||
|
||||
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.
|
||||
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).
|
||||
6. Capture follow-up items and leave a clear next-task suggestion.
|
||||
5. Keep constraints explicit.
|
||||
6. Capture follow-up items and a clear next-task suggestion.
|
||||
|
||||
## Files to Read First
|
||||
|
||||
- AGENTS.md
|
||||
- ai/repo-context.md
|
||||
- ai/architecture-index.md
|
||||
- docs/stats-section-functional-plan.md
|
||||
- docs/annual-ranking-snapshot-runbook.md
|
||||
- backend/app/rcon_historical_leaderboards.py
|
||||
- backend/app/rcon_annual_rankings.py
|
||||
- frontend/stats.html
|
||||
- frontend/assets/js/stats.js
|
||||
- `AGENTS.md`
|
||||
- `ai/repo-context.md`
|
||||
- `ai/architecture-index.md`
|
||||
- `docs/stats-section-functional-plan.md`
|
||||
- `docs/annual-ranking-snapshot-runbook.md`
|
||||
- `backend/app/rcon_historical_leaderboards.py`
|
||||
- `backend/app/rcon_annual_rankings.py`
|
||||
- `frontend/stats.html`
|
||||
- `frontend/assets/js/stats.js`
|
||||
|
||||
## Expected Files to Modify
|
||||
|
||||
- docs/global-ranking-page-plan.md
|
||||
- ai/tasks/done/TASK-180-define-global-ranking-page-contract.md
|
||||
- `docs/global-ranking-page-plan.md`
|
||||
- `ai/tasks/done/TASK-180-define-global-ranking-page-contract.md`
|
||||
|
||||
## Constraints
|
||||
|
||||
@@ -60,20 +60,22 @@ Stats focuses on one player workflow (search and personal performance), while Ra
|
||||
- No reintroduction of Comunidad Hispana #03.
|
||||
- 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
|
||||
|
||||
- Documented Ranking goal and how it differs from Stats.
|
||||
- Documented user flow:
|
||||
- open Ranking page
|
||||
- choose timeframe
|
||||
- choose server
|
||||
- choose metric
|
||||
- view top players
|
||||
- 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:
|
||||
- Created `docs/global-ranking-page-plan.md`.
|
||||
- Documented a dedicated `Ranking` page contract separate from `Stats`.
|
||||
- Defined dedicated endpoint direction as `GET /api/ranking`.
|
||||
- Documented timeframe behavior:
|
||||
- weekly/monthly from RCON materialized leaderboard reads
|
||||
- annual from persisted annual snapshots
|
||||
- Documented required payload fields:
|
||||
- `ranking_position`
|
||||
- `player_id`
|
||||
- `player_name`
|
||||
@@ -85,26 +87,25 @@ Stats focuses on one player workflow (search and personal performance), while Ra
|
||||
- `kd_ratio`
|
||||
- `window_start`
|
||||
- `window_end`
|
||||
- `snapshot_status` where applicable
|
||||
- Documented UI states:
|
||||
- `snapshot_status`
|
||||
- Documented required UI states:
|
||||
- loading
|
||||
- backend offline
|
||||
- no data
|
||||
- annual snapshot missing
|
||||
- unsupported metric
|
||||
- controlled error
|
||||
- Documented non-goals:
|
||||
- Elo/MMR
|
||||
- authentication
|
||||
- private profile expansion
|
||||
- advanced charts
|
||||
- large database changes
|
||||
- reintroducing Comunidad Hispana #03
|
||||
- Explicitly preserved constraints:
|
||||
- no Elo/MMR
|
||||
- no Comunidad Hispana #03
|
||||
- no public-scoreboard primary path while RCON exists
|
||||
|
||||
## Validation
|
||||
Validation performed:
|
||||
|
||||
- Confirm `docs/global-ranking-page-plan.md` exists.
|
||||
- Confirm no backend/frontend files were modified.
|
||||
- Run `git diff --name-only` within task scope.
|
||||
- If no automated tests apply, document that explicitly in Outcome.
|
||||
- Confirmed documentation file creation.
|
||||
- Kept changes out of `backend/` and `frontend/`.
|
||||
- Automated tests do not apply to this documentation-only task.
|
||||
|
||||
Recommended next task:
|
||||
|
||||
- `TASK-181-add-global-ranking-backend-support`
|
||||
110
ai/tasks/done/TASK-181-add-global-ranking-backend-support.md
Normal file
110
ai/tasks/done/TASK-181-add-global-ranking-backend-support.md
Normal 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`
|
||||
99
ai/tasks/done/TASK-182-add-global-ranking-frontend-page.md
Normal file
99
ai/tasks/done/TASK-182-add-global-ranking-frontend-page.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -50,6 +50,7 @@ from .historical_storage import (
|
||||
)
|
||||
from .rcon_historical_read_model import get_rcon_historical_match_detail
|
||||
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 get_rcon_materialized_player_stats
|
||||
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")
|
||||
|
||||
|
||||
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:
|
||||
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(
|
||||
*,
|
||||
limit: int = 10,
|
||||
|
||||
@@ -8,6 +8,7 @@ from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from .config import get_historical_data_source_kind
|
||||
from .payloads import (
|
||||
build_global_ranking_payload,
|
||||
build_stats_player_profile_payload,
|
||||
build_community_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:
|
||||
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":
|
||||
server_slug = parse_qs(parsed.query).get("server", [None])[0]
|
||||
if not server_slug:
|
||||
@@ -471,8 +504,28 @@ def _parse_year(query: str) -> int | None:
|
||||
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:
|
||||
params = parse_qs(query)
|
||||
if "limit" not in params:
|
||||
return default
|
||||
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",
|
||||
}
|
||||
|
||||
261
docs/global-ranking-page-plan.md
Normal file
261
docs/global-ranking-page-plan.md
Normal 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.
|
||||
@@ -985,6 +985,11 @@ h2 {
|
||||
color: #e2d7a9;
|
||||
}
|
||||
|
||||
.stats-state--ready {
|
||||
border-color: rgba(183, 201, 125, 0.32);
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
|
||||
.stats-result-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
@@ -1225,6 +1230,121 @@ h2 {
|
||||
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 {
|
||||
border-color: rgba(159, 168, 141, 0.18);
|
||||
background: linear-gradient(180deg, rgba(42, 46, 39, 0.5), rgba(19, 22, 17, 0.7));
|
||||
@@ -1296,6 +1416,16 @@ h2 {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ranking-form__actions {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ranking-table th,
|
||||
.ranking-table td {
|
||||
padding: 12px 10px;
|
||||
}
|
||||
|
||||
.logo-frame {
|
||||
min-height: 180px;
|
||||
padding: 16px 18px;
|
||||
|
||||
391
frontend/assets/js/ranking.js
Normal file
391
frontend/assets/js/ranking.js
Normal 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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
});
|
||||
@@ -51,6 +51,9 @@
|
||||
<a class="secondary-button" href="./stats.html">
|
||||
Stats
|
||||
</a>
|
||||
<a class="secondary-button" href="./ranking.html">
|
||||
Ranking
|
||||
</a>
|
||||
<a class="secondary-button" href="./historico.html">
|
||||
Ver historico propio
|
||||
</a>
|
||||
|
||||
149
frontend/ranking.html
Normal file
149
frontend/ranking.html
Normal 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>
|
||||
@@ -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")
|
||||
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({
|
||||
"checked": [
|
||||
"health",
|
||||
"stats-player-search",
|
||||
"stats-player-profile",
|
||||
"stats-annual-ranking",
|
||||
"global-ranking",
|
||||
],
|
||||
"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 []),
|
||||
"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."
|
||||
}
|
||||
|
||||
$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."
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user