Add ranking metric expansion

This commit is contained in:
devRaGonSa
2026-06-09 07:32:29 +02:00
parent f74ac17f48
commit 12a215d11a
13 changed files with 869 additions and 94 deletions

View File

@@ -1,7 +1,7 @@
--- ---
id: TASK-184-define-ranking-metric-expansion-contract id: TASK-184-define-ranking-metric-expansion-contract
title: Define ranking metric expansion contract title: Define ranking metric expansion contract
status: pending status: done
type: documentation type: documentation
team: Analista team: Analista
supporting_teams: supporting_teams:
@@ -102,17 +102,73 @@ Before completing the task ensure:
## Outcome ## Outcome
Document: Supported Ranking V1.1 metrics documented in `docs/global-ranking-page-plan.md`:
- supported Ranking V1.1 metrics - `kills`
- formula for each metric - `deaths`
- ordering and tie-break expectations - `teamkills`
- supported timeframes by metric - `matches_considered`
- annual snapshot limitations and safety rules - `kd_ratio`
- expected payload adjustments, if any - `kills_per_match`
- controlled error behavior for unsupported metrics
- validation performed Formula and aggregation rules documented:
- explicit note that no automated tests apply if this remains documentation-only
- `kills = SUM(kills)`
- `deaths = SUM(deaths)`
- `teamkills = SUM(teamkills)`
- `matches_considered = COUNT(DISTINCT match_key)`
- `kd_ratio = SUM(kills) / SUM(deaths)`
- `kills_per_match = SUM(kills) / COUNT(DISTINCT match_key)`
Safety rules documented:
- `deaths=0` returns finite display-safe `kd_ratio` using kills as the fallback value
- `matches_considered=0` returns `kills_per_match = 0`
Ordering and tie-break expectations documented:
- totals metrics sort by active metric desc, then stable tie-break fields
- `matches_considered` ties break on `kills` desc, then `player_name` asc
- ratio metrics tie-break on `kills` desc, `matches_considered` desc, `player_name` asc
Supported timeframes by metric documented:
- weekly: all V1.1 metrics through the materialized RCON runtime leaderboard
- monthly: all V1.1 metrics through the materialized RCON runtime leaderboard
- annual: snapshot-safe path only; `kills` required, extra metrics only if an explicit snapshot-backed read path exists
Annual snapshot limitations and safety rules documented:
- no runtime annual recomputation on public requests
- unsupported annual metrics must return controlled `400`
- until extra annual snapshots exist, annual remains effectively `kills`-only
Expected payload adjustments documented:
- `metric_value` remains the active display/sort field
- weekly/monthly payloads may include rounded ratio fields and `kills_per_match`
- annual payload shape remains stable and only expands when snapshot-backed safely
Controlled error behavior documented:
- unsupported metric, unsupported timeframe and unsupported annual metric must fail with controlled request-validation errors
- backend must not silently downgrade unsupported requests to `kills`
Validation performed:
- reviewed `docs/global-ranking-page-plan.md`
- reviewed `docs/stats-section-functional-plan.md`
- reviewed `docs/annual-ranking-snapshot-runbook.md`
- reviewed `backend/app/rcon_historical_leaderboards.py`
- reviewed `backend/app/rcon_annual_rankings.py`
- reviewed `backend/app/routes.py`
- reviewed `backend/app/payloads.py`
- reviewed `ai/tasks/done/TASK-183-review-global-ranking-implementation.md`
- confirmed `git diff --name-only` scope stays documentation-only for this task
Automated tests:
- No automated tests apply because this task remained documentation-only.
## Change Budget ## Change Budget

View File

@@ -1,7 +1,7 @@
--- ---
id: TASK-185-add-ranking-extra-metrics-backend-support id: TASK-185-add-ranking-extra-metrics-backend-support
title: Add ranking extra metrics backend support title: Add ranking extra metrics backend support
status: pending status: done
type: backend type: backend
team: Backend Senior team: Backend Senior
supporting_teams: supporting_teams:
@@ -105,14 +105,72 @@ Before completing the task ensure:
## Outcome ## Outcome
Document: Metrics added for `GET /api/ranking` weekly/monthly reads:
- metrics added - `kills`
- formulas applied in backend ranking logic - `deaths`
- annual metric behavior and limitations - `teamkills`
- validations executed - `matches_considered`
- known limitations - `kd_ratio`
- recommended next task: expose the new metrics safely in Ranking frontend UX - `kills_per_match`
Backend formulas and ordering applied:
- `kills = SUM(kills)` ordered by `kills` desc, `matches_considered` desc, `player_name` asc
- `deaths = SUM(deaths)` ordered by `deaths` desc, `matches_considered` desc, `player_name` asc
- `teamkills = SUM(teamkills)` ordered by `teamkills` desc, `matches_considered` desc, `player_name` asc
- `matches_considered = COUNT(DISTINCT match_key)` ordered by `matches_considered` desc, `kills` desc, `player_name` asc
- `kd_ratio = SUM(kills) / SUM(deaths)` with `deaths=0 -> kills`, ordered by `kd_ratio` desc, `kills` desc, `matches_considered` desc, `player_name` asc
- `kills_per_match = SUM(kills) / COUNT(DISTINCT match_key)` with `matches_considered=0 -> 0`, ordered by `kills_per_match` desc, `kills` desc, `matches_considered` desc, `player_name` asc
Implementation summary:
- `backend/app/rcon_historical_leaderboards.py` now supports the V1.1 ranking metrics while preserving the existing materialized RCON read model.
- `backend/app/routes.py` now validates the expanded public Ranking metric set without changing the endpoint shape.
- `backend/app/payloads.py` now preserves decimal `metric_value` when needed and exposes `kills_per_match` in normalized ranking items.
- `backend/app/rcon_annual_rankings.py` remains snapshot-safe and returns a controlled annual-specific `400` for unsupported annual metrics.
- `scripts/run-stats-validation.ps1` now covers the new happy paths and annual guardrail failures.
Annual metric behavior and limitations:
- annual remains snapshot-backed only
- `metric=kills` remains supported
- extra annual metrics currently return controlled `400`
- no runtime full-year recomputation was introduced
Validations executed:
- `python -m compileall backend/app/routes.py backend/app/payloads.py backend/app/rcon_historical_leaderboards.py backend/app/rcon_annual_rankings.py`
- `powershell -ExecutionPolicy Bypass -File scripts/run-stats-validation.ps1`
- `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1`
Validation notes:
- live backend HTTP validation at `http://127.0.0.1:8000` was not available during execution
- route-contract validation still passed through local Python imports
Scope notes:
- task-owned modifications stayed within:
- `backend/app/rcon_historical_leaderboards.py`
- `backend/app/routes.py`
- `backend/app/payloads.py`
- `backend/app/rcon_annual_rankings.py`
- `scripts/run-stats-validation.ps1`
- `git diff --name-only` also showed pre-existing or previous-task changes outside this task:
- moved task files from `TASK-184` / `TASK-185`
- `docs/global-ranking-page-plan.md` from `TASK-184`
- unrelated existing workspace change `frontend/assets/img/weapons/black/gewehr_black.svg`
- those files were not modified as part of this backend task.
Known limitations:
- annual extra metrics are intentionally blocked until an explicit snapshot-backed implementation exists
- ranking item decimals are validated through route-contract checks, not through a running backend HTTP instance in this run
Recommended next task:
- expose the new metrics safely in the Ranking frontend UX.
## Change Budget ## Change Budget

View File

@@ -1,7 +1,7 @@
--- ---
id: TASK-186-polish-ranking-metric-ux-and-limits id: TASK-186-polish-ranking-metric-ux-and-limits
title: Polish ranking metric UX and limits title: Polish ranking metric UX and limits
status: pending status: done
type: frontend type: frontend
team: Frontend Senior team: Frontend Senior
supporting_teams: supporting_teams:
@@ -101,14 +101,88 @@ Before completing the task ensure:
## Outcome ## Outcome
Document: Metrics exposed in Ranking UI:
- metrics exposed in UI - `kills`
- annual behavior in the UX - `deaths`
- UI limit choices - `teamkills`
- error states covered - `matches_considered`
- validations executed - `kd_ratio`
- recommended follow-ups, if any - `kills_per_match`
Frontend UX changes delivered:
- `frontend/ranking.html` now exposes the expanded metric selector and wider limit options.
- the ranking table now highlights the active metric dynamically while still showing the core supporting stats (`kills`, `deaths`, `teamkills`, `matches_considered`, `kd_ratio`, `kills_per_match`)
- the metadata strip now shows the active timeframe, active server, active metric, limit, window and source more explicitly
- `frontend/assets/js/ranking.js` now restores filter state from URL parameters and keeps the URL synced as filters change
- `frontend/stats.html` now includes a minimal direct link back to `Ranking`
Annual behavior in the UX:
- annual keeps `kills` as the only selectable active metric
- when the user switches to annual, non-snapshot-safe metrics are disabled in the selector
- the page shows an explicit note that annual remains `kills`-only until additional safe snapshots exist
- annual unsupported-metric and missing-snapshot states remain clearly differentiated
UI limit choices:
- `Top 5`
- `Top 10`
- `Top 20`
- `Top 50`
- `Top 100`
Error and state coverage improved:
- dedicated invalid-limit message for manual URL or parameter manipulation
- dedicated annual kills-only warning
- preserved unsupported metric message
- preserved unsupported timeframe message
- preserved backend offline fallback path
- preserved annual snapshot missing state
- preserved empty-ready states
Validation updates:
- `scripts/run-stats-validation.ps1` now asserts the new Ranking metric options, annual kills-only guidance, URL-state handling and the Stats-to-Ranking link
Validations executed:
- `node --check frontend/assets/js/ranking.js`
- `node --check frontend/assets/js/stats.js`
- `powershell -ExecutionPolicy Bypass -File scripts/run-stats-validation.ps1`
- `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1`
- temporary static serving with `python -m http.server 8081`
- confirmed HTTP `200` for:
- `ranking.html`
- `assets/js/ranking.js`
- `stats.html`
- `index.html`
Validation notes:
- local backend HTTP validation at `http://127.0.0.1:8000` was unavailable during the run
- offline fallback behavior was therefore validated through the existing frontend logic plus route-contract checks from the shared validation script
Scope notes:
- task-owned frontend changes stayed within:
- `frontend/ranking.html`
- `frontend/assets/js/ranking.js`
- `frontend/assets/css/styles.css`
- `frontend/stats.html`
- `scripts/run-stats-validation.ps1`
- `git diff --name-only` also shows previous-task backend/docs changes and an unrelated existing workspace change:
- moved task files from `TASK-184` / `TASK-185` / `TASK-186`
- `backend/app/*` and `docs/global-ranking-page-plan.md` from prior tasks in this run
- `frontend/assets/img/weapons/black/gewehr_black.svg`
- those files were not modified as part of this frontend task.
Recommended follow-ups:
- if a live backend session becomes available, run an explicit browser-side validation pass for every supported metric against real responses
- if annual extra metrics are introduced later, keep the current UX branch but switch from disabled options to active snapshot-backed support only when backend snapshots exist.
## Change Budget ## Change Budget

View File

@@ -500,22 +500,39 @@ def _normalize_global_ranking_items(items: object) -> list[dict[str, object]]:
for item in items if isinstance(items, list) else []: for item in items if isinstance(items, list) else []:
if not isinstance(item, dict): if not isinstance(item, dict):
continue continue
matches_considered = int(item.get("matches_considered") or 0)
kills = int(item.get("kills") or 0)
normalized_items.append( normalized_items.append(
{ {
"ranking_position": int(item.get("ranking_position") or 0), "ranking_position": int(item.get("ranking_position") or 0),
"player_id": item.get("player_id"), "player_id": item.get("player_id"),
"player_name": item.get("player_name"), "player_name": item.get("player_name"),
"metric_value": int(item.get("metric_value") or 0), "metric_value": _coerce_public_metric_value(item.get("metric_value")),
"matches_considered": int(item.get("matches_considered") or 0), "matches_considered": matches_considered,
"kills": int(item.get("kills") or 0), "kills": kills,
"deaths": int(item.get("deaths") or 0), "deaths": int(item.get("deaths") or 0),
"teamkills": int(item.get("teamkills") or 0), "teamkills": int(item.get("teamkills") or 0),
"kd_ratio": float(item.get("kd_ratio") or 0.0), "kd_ratio": float(item.get("kd_ratio") or 0.0),
"kills_per_match": float(
item.get("kills_per_match")
if item.get("kills_per_match") is not None
else round(kills / matches_considered, 2) if matches_considered else 0.0
),
} }
) )
return normalized_items return normalized_items
def _coerce_public_metric_value(value: object) -> int | float:
try:
numeric = float(value or 0)
except (TypeError, ValueError):
return 0
if numeric.is_integer():
return int(numeric)
return round(numeric, 2)
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
@@ -735,12 +752,25 @@ def build_annual_ranking_snapshot_payload(
"ranking_position": int(item.get("ranking_position") or 0), "ranking_position": int(item.get("ranking_position") or 0),
"player_id": item.get("player_id"), "player_id": item.get("player_id"),
"player_name": item.get("player_name"), "player_name": item.get("player_name"),
"metric_value": int(item.get("metric_value") or 0), "metric_value": _coerce_public_metric_value(item.get("metric_value")),
"matches_considered": int(item.get("matches_considered") or 0), "matches_considered": int(item.get("matches_considered") or 0),
"kills": int(item.get("kills") or 0), "kills": int(item.get("kills") or 0),
"deaths": int(item.get("deaths") or 0), "deaths": int(item.get("deaths") or 0),
"teamkills": int(item.get("teamkills") or 0), "teamkills": int(item.get("teamkills") or 0),
"kd_ratio": float(item.get("kd_ratio") or 0.0), "kd_ratio": float(item.get("kd_ratio") or 0.0),
"kills_per_match": (
float(item.get("kills_per_match"))
if item.get("kills_per_match") is not None
else (
round(
int(item.get("kills") or 0)
/ int(item.get("matches_considered") or 0),
2,
)
if int(item.get("matches_considered") or 0) > 0
else 0.0
)
),
} }
for item in items for item in items
if isinstance(item, dict) if isinstance(item, dict)

View File

@@ -205,7 +205,9 @@ def _normalize_server_key(server_key: str | None) -> str:
def _normalize_metric(metric: str) -> str: def _normalize_metric(metric: str) -> str:
normalized = str(metric or "kills").strip().lower() normalized = str(metric or "kills").strip().lower()
if normalized != "kills": if normalized != "kills":
raise ValueError("Only metric 'kills' is supported for annual ranking endpoints.") raise ValueError(
f"Metric '{normalized}' is not supported for annual ranking snapshots."
)
return normalized return normalized

View File

@@ -17,7 +17,16 @@ from .rcon_admin_log_materialization import (
from .sqlite_utils import connect_sqlite_readonly from .sqlite_utils import connect_sqlite_readonly
LeaderboardTimeframe = Literal["weekly", "monthly"] LeaderboardTimeframe = Literal["weekly", "monthly"]
LeaderboardMetric = Literal["kills", "deaths", "matches_over_100_kills", "support"] LeaderboardMetric = Literal[
"kills",
"deaths",
"teamkills",
"matches_considered",
"kd_ratio",
"kills_per_match",
"matches_over_100_kills",
"support",
]
def build_rcon_materialized_leaderboard_snapshot_payload( def build_rcon_materialized_leaderboard_snapshot_payload(
@@ -197,12 +206,7 @@ def _fetch_leaderboard_rows(
window_end: datetime, window_end: datetime,
) -> list[dict[str, object]]: ) -> list[dict[str, object]]:
scope_sql, scope_params = _build_scope_sql(server_key) scope_sql, scope_params = _build_scope_sql(server_key)
metric_sql = { metric_sql, having_sql, order_by_sql = _resolve_metric_sql(metric)
"kills": "SUM(COALESCE(stats.kills, 0))",
"deaths": "SUM(COALESCE(stats.deaths, 0))",
"matches_over_100_kills": "SUM(CASE WHEN COALESCE(stats.kills, 0) >= 100 THEN 1 ELSE 0 END)",
}[metric]
having_sql = f"HAVING {metric_sql} > 0"
params: list[object] = [ params: list[object] = [
_to_iso(window_start), _to_iso(window_start),
_to_iso(window_end), _to_iso(window_end),
@@ -230,7 +234,7 @@ def _fetch_leaderboard_rows(
AND TRIM(COALESCE(stats.player_name, '')) != '' AND TRIM(COALESCE(stats.player_name, '')) != ''
GROUP BY stats.player_id, stats.player_name GROUP BY stats.player_id, stats.player_name
{having_sql} {having_sql}
ORDER BY metric_value DESC, matches_considered DESC, stats.player_name ASC ORDER BY {order_by_sql}
LIMIT ? LIMIT ?
""", """,
[MATCH_RESULT_SOURCE, *params], [MATCH_RESULT_SOURCE, *params],
@@ -431,6 +435,9 @@ def _count_matches(
def _build_item(row: dict[str, object], *, index: int) -> dict[str, object]: def _build_item(row: dict[str, object], *, index: int) -> dict[str, object]:
kills = _coerce_int(row.get("kills")) kills = _coerce_int(row.get("kills"))
deaths = _coerce_int(row.get("deaths")) deaths = _coerce_int(row.get("deaths"))
matches_considered = _coerce_int(row.get("matches_considered"))
kd_ratio = round(kills / deaths, 2) if deaths else float(kills)
kills_per_match = round(kills / matches_considered, 2) if matches_considered else 0.0
return { return {
"ranking_position": index, "ranking_position": index,
"player": { "player": {
@@ -439,12 +446,13 @@ def _build_item(row: dict[str, object], *, index: int) -> dict[str, object]:
}, },
"player_id": row.get("player_id"), "player_id": row.get("player_id"),
"player_name": row.get("player_name"), "player_name": row.get("player_name"),
"metric_value": _coerce_int(row.get("metric_value")), "metric_value": _coerce_metric_value(row.get("metric_value")),
"matches_considered": _coerce_int(row.get("matches_considered")), "matches_considered": matches_considered,
"kills": kills, "kills": kills,
"deaths": deaths, "deaths": deaths,
"teamkills": _coerce_int(row.get("teamkills")), "teamkills": _coerce_int(row.get("teamkills")),
"kd_ratio": round(kills / deaths, 2) if deaths else float(kills), "kd_ratio": kd_ratio,
"kills_per_match": kills_per_match,
} }
@@ -554,7 +562,16 @@ def _normalize_timeframe(value: str) -> LeaderboardTimeframe:
def _normalize_metric(value: str) -> LeaderboardMetric: def _normalize_metric(value: str) -> LeaderboardMetric:
normalized = str(value or "kills").strip().lower() normalized = str(value or "kills").strip().lower()
if normalized in {"kills", "deaths", "matches_over_100_kills", "support"}: if normalized in {
"kills",
"deaths",
"teamkills",
"matches_considered",
"kd_ratio",
"kills_per_match",
"matches_over_100_kills",
"support",
}:
return normalized # type: ignore[return-value] return normalized # type: ignore[return-value]
return "kills" return "kills"
@@ -565,6 +582,10 @@ def _build_title(*, metric: str, timeframe: str, server_id: str | None) -> str:
metric_label = { metric_label = {
"kills": "Top kills", "kills": "Top kills",
"deaths": "Top muertes", "deaths": "Top muertes",
"teamkills": "Top teamkills",
"matches_considered": "Top partidas jugadas",
"kd_ratio": "Top K/D",
"kills_per_match": "Top kills por partida",
"matches_over_100_kills": "Partidas 100+ kills", "matches_over_100_kills": "Partidas 100+ kills",
"support": "Top soporte", "support": "Top soporte",
}.get(metric, "Top kills") }.get(metric, "Top kills")
@@ -578,6 +599,69 @@ def _coerce_int(value: object) -> int:
return 0 return 0
def _coerce_metric_value(value: object) -> int | float:
numeric = _coerce_float(value)
if numeric.is_integer():
return int(numeric)
return round(numeric, 2)
def _coerce_float(value: object) -> float:
try:
return float(value or 0)
except (TypeError, ValueError):
return 0.0
def _resolve_metric_sql(metric: str) -> tuple[str, str, str]:
metric_sql_by_metric = {
"kills": "SUM(COALESCE(stats.kills, 0))",
"deaths": "SUM(COALESCE(stats.deaths, 0))",
"teamkills": "SUM(COALESCE(stats.teamkills, 0))",
"matches_considered": "COUNT(DISTINCT stats.match_key)",
"kd_ratio": (
"CASE "
"WHEN SUM(COALESCE(stats.deaths, 0)) > 0 "
"THEN ROUND(CAST(SUM(COALESCE(stats.kills, 0)) AS REAL) / SUM(COALESCE(stats.deaths, 0)), 2) "
"ELSE CAST(SUM(COALESCE(stats.kills, 0)) AS REAL) "
"END"
),
"kills_per_match": (
"CASE "
"WHEN COUNT(DISTINCT stats.match_key) > 0 "
"THEN ROUND(CAST(SUM(COALESCE(stats.kills, 0)) AS REAL) / COUNT(DISTINCT stats.match_key), 2) "
"ELSE 0.0 "
"END"
),
"matches_over_100_kills": "SUM(CASE WHEN COALESCE(stats.kills, 0) >= 100 THEN 1 ELSE 0 END)",
}
having_sql_by_metric = {
"kills": "HAVING SUM(COALESCE(stats.kills, 0)) > 0",
"deaths": "HAVING SUM(COALESCE(stats.deaths, 0)) > 0",
"teamkills": "HAVING SUM(COALESCE(stats.teamkills, 0)) > 0",
"matches_considered": "HAVING COUNT(DISTINCT stats.match_key) > 0",
"kd_ratio": "HAVING SUM(COALESCE(stats.kills, 0)) > 0",
"kills_per_match": "HAVING COUNT(DISTINCT stats.match_key) > 0 AND SUM(COALESCE(stats.kills, 0)) > 0",
"matches_over_100_kills": "HAVING SUM(CASE WHEN COALESCE(stats.kills, 0) >= 100 THEN 1 ELSE 0 END) > 0",
}
order_by_sql_by_metric = {
"kills": "metric_value DESC, matches_considered DESC, stats.player_name ASC",
"deaths": "metric_value DESC, matches_considered DESC, stats.player_name ASC",
"teamkills": "metric_value DESC, matches_considered DESC, stats.player_name ASC",
"matches_considered": "metric_value DESC, kills DESC, stats.player_name ASC",
"kd_ratio": "metric_value DESC, kills DESC, matches_considered DESC, stats.player_name ASC",
"kills_per_match": "metric_value DESC, kills DESC, matches_considered DESC, stats.player_name ASC",
"matches_over_100_kills": "metric_value DESC, matches_considered DESC, stats.player_name ASC",
}
if metric == "support":
raise ValueError("support is handled separately")
return (
metric_sql_by_metric[metric],
having_sql_by_metric[metric],
order_by_sql_by_metric[metric],
)
def _parse_datetime(value: object) -> datetime | None: def _parse_datetime(value: object) -> datetime | None:
if isinstance(value, datetime): if isinstance(value, datetime):
parsed = value parsed = value

View File

@@ -49,6 +49,15 @@ from .payloads import (
from .rcon_historical_leaderboards import build_rcon_materialized_leaderboard_snapshot_payload from .rcon_historical_leaderboards import build_rcon_materialized_leaderboard_snapshot_payload
from .scoreboard_origins import get_trusted_public_scoreboard_origin from .scoreboard_origins import get_trusted_public_scoreboard_origin
RANKING_METRICS = {
"kills",
"deaths",
"teamkills",
"matches_considered",
"kd_ratio",
"kills_per_match",
}
GET_ROUTES = { GET_ROUTES = {
"/health": build_health_payload, "/health": build_health_payload,
@@ -118,7 +127,7 @@ def resolve_get_payload(path: str) -> tuple[HTTPStatus | None, dict[str, object]
if timeframe not in {"weekly", "monthly", "annual"}: if timeframe not in {"weekly", "monthly", "annual"}:
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid timeframe parameter") return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid timeframe parameter")
metric = params.get("metric", ["kills"])[0] metric = params.get("metric", ["kills"])[0]
if metric != "kills": if metric not in RANKING_METRICS:
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid metric parameter") return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid metric parameter")
limit = _parse_limit(parsed.query) limit = _parse_limit(parsed.query)
if limit is None: if limit is None:

View File

@@ -42,6 +42,56 @@ The first Ranking page supports:
- filter changes without manual page reload where feasible - filter changes without manual page reload where feasible
- clear differentiation between `ready`, `missing`, `empty`, `offline` and request-error states - clear differentiation between `ready`, `missing`, `empty`, `offline` and request-error states
## V1.1 Metric Expansion Contract
V1.1 expands `Ranking global` only within the existing RCON-first read paths and annual snapshot boundary.
Supported metrics:
- `kills`
- `deaths`
- `teamkills`
- `matches_considered`
- `kd_ratio`
- `kills_per_match`
Calculation rules:
- `kills`: `SUM(kills)`
- `deaths`: `SUM(deaths)`
- `teamkills`: `SUM(teamkills)`
- `matches_considered`: `COUNT(DISTINCT match_key)` across closed matches in the selected window
- `kd_ratio`: `SUM(kills) / SUM(deaths)`
- `kills_per_match`: `SUM(kills) / COUNT(DISTINCT match_key)`
Safe division rules:
- if `deaths = 0`, `kd_ratio` returns `kills` as a finite display-safe value rather than dividing by zero
- if `matches_considered = 0`, `kills_per_match` returns `0`
- backend should return rounded display-safe decimal values for ratio metrics
Ordering and tie-break rules:
- `kills`: order by `kills` desc, `matches_considered` desc, `player_name` asc
- `deaths`: order by `deaths` desc, `matches_considered` desc, `player_name` asc
- `teamkills`: order by `teamkills` desc, `matches_considered` desc, `player_name` asc
- `matches_considered`: order by `matches_considered` desc, `kills` desc, `player_name` asc
- `kd_ratio`: order by `kd_ratio` desc, `kills` desc, `matches_considered` desc, `player_name` asc
- `kills_per_match`: order by `kills_per_match` desc, `kills` desc, `matches_considered` desc, `player_name` asc
Timeframe support:
- `weekly`: supports all V1.1 metrics through the existing materialized RCON runtime read model
- `monthly`: supports all V1.1 metrics through the existing materialized RCON runtime read model
- `annual`: remains snapshot-safe first; `kills` is required support and extra annual metrics are allowed only when a precomputed annual snapshot already exists for that metric
Annual safety boundary:
- annual requests must remain snapshot-backed
- annual requests must not trigger full-year recomputation in the public request path
- if an annual metric does not have a safe snapshot-backed read path, backend must return controlled `400` instead of degrading to runtime aggregation
- until extra annual snapshots are explicitly implemented, annual support is effectively `kills`-only
## V1 Non-goals ## V1 Non-goals
- Elo/MMR - Elo/MMR
@@ -73,6 +123,7 @@ The first Ranking page supports:
- Reader: existing weekly/monthly selection logic in `select_leaderboard_window(...)`. - Reader: existing weekly/monthly selection logic in `select_leaderboard_window(...)`.
- Ordering: metric desc, `matches_considered` desc, `player_name` asc. - Ordering: metric desc, `matches_considered` desc, `player_name` asc.
- V1 metric commitment: `kills`. - V1 metric commitment: `kills`.
- V1.1 metric support: `kills`, `deaths`, `teamkills`, `matches_considered`, `kd_ratio`, `kills_per_match`.
### Annual ### Annual
@@ -80,13 +131,15 @@ The first Ranking page supports:
- Reader: existing annual snapshot loader. - Reader: existing annual snapshot loader.
- No annual recomputation during public requests. - No annual recomputation during public requests.
- Missing annual data must surface as a controlled `snapshot_status="missing"` state. - Missing annual data must surface as a controlled `snapshot_status="missing"` state.
- V1 required annual metric: `kills`.
- Extra annual metrics are out of scope until their snapshot generation and read path are explicitly implemented.
## API Contract Direction ## API Contract Direction
### Dedicated Ranking endpoint ### Dedicated Ranking endpoint
```http ```http
GET /api/ranking?timeframe=weekly|monthly|annual&server_id=<server-or-all>&metric=kills&limit=20&year=<year-when-annual> GET /api/ranking?timeframe=weekly|monthly|annual&server_id=<server-or-all>&metric=<metric>&limit=20&year=<year-when-annual>
``` ```
Purpose: Purpose:
@@ -104,10 +157,13 @@ Query rules:
Validation rules: Validation rules:
- allowed `timeframe`: `weekly`, `monthly`, `annual` - allowed `timeframe`: `weekly`, `monthly`, `annual`
- V1 allowed `metric`: `kills` - V1.1 allowed `metric` for weekly/monthly: `kills`, `deaths`, `teamkills`, `matches_considered`, `kd_ratio`, `kills_per_match`
- allowed `annual` metric by default: `kills`
- annual extra metrics require an explicit snapshot-backed implementation; otherwise return `400`
- allowed `limit`: `1..100` - allowed `limit`: `1..100`
- `year` must be a positive integer when annual is requested - `year` must be a positive integer when annual is requested
- annual requests ignore weekly/monthly runtime window policy and read snapshots only - annual requests ignore weekly/monthly runtime window policy and read snapshots only
- unsupported metric, unsupported timeframe or unsupported annual metric must return a controlled request-validation error and must not silently downgrade to `kills`
## Response Contract ## Response Contract
@@ -118,7 +174,7 @@ Validation rules:
"page_kind": "global-ranking", "page_kind": "global-ranking",
"timeframe": "monthly", "timeframe": "monthly",
"server_id": "all", "server_id": "all",
"metric": "kills", "metric": "kills_per_match",
"limit": 20, "limit": 20,
"requested_limit": 20, "requested_limit": 20,
"window_start": "2026-06-01T00:00:00Z", "window_start": "2026-06-01T00:00:00Z",
@@ -137,12 +193,13 @@ Validation rules:
"ranking_position": 1, "ranking_position": 1,
"player_id": "76561198000000000", "player_id": "76561198000000000",
"player_name": "Rambo", "player_name": "Rambo",
"metric_value": 421, "metric_value": 30.07,
"matches_considered": 14, "matches_considered": 14,
"kills": 421, "kills": 421,
"deaths": 280, "deaths": 280,
"teamkills": 3, "teamkills": 3,
"kd_ratio": 1.5 "kd_ratio": 1.5,
"kills_per_match": 30.07
} }
] ]
} }
@@ -155,7 +212,7 @@ Common top-level fields:
- `timeframe`: `weekly`, `monthly`, `annual` - `timeframe`: `weekly`, `monthly`, `annual`
- `server_id`: `all` or one supported active server id - `server_id`: `all` or one supported active server id
- `metric`: `kills` in V1 - `metric`: requested supported metric
- `limit`: effective served limit - `limit`: effective served limit
- `requested_limit`: requested client limit - `requested_limit`: requested client limit
- `snapshot_status`: always present for frontend state handling - `snapshot_status`: always present for frontend state handling
@@ -172,6 +229,13 @@ Required item fields:
- `deaths` - `deaths`
- `teamkills` - `teamkills`
- `kd_ratio` - `kd_ratio`
- `kills_per_match`
Conditional item rules:
- `metric_value` may be integer or rounded decimal depending on the requested metric
- `kills_per_match` should be present when that metric is supported in the response contract
- `matches_considered` remains required because it is both a displayed field and a common tie-break input
Weekly/monthly-only metadata: Weekly/monthly-only metadata:
@@ -207,6 +271,8 @@ Annual-only metadata:
- Read only from annual snapshots. - Read only from annual snapshots.
- `snapshot_status="ready"` with `items=[]` is a valid empty-ready state. - `snapshot_status="ready"` with `items=[]` is a valid empty-ready state.
- `snapshot_status="missing"` is not a backend crash and must be rendered distinctly. - `snapshot_status="missing"` is not a backend crash and must be rendered distinctly.
- Annual must not pretend support for metrics that do not have precomputed snapshots.
- If client requests an unsupported annual metric, backend should return `400` rather than an empty-ready payload.
## UI State Contract ## UI State Contract
@@ -226,6 +292,7 @@ Expected rendering behavior:
- no data keeps filters visible and explains that there are no rows for the active scope - 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 - annual snapshot missing explains that the annual snapshot has not been generated yet
- unsupported metric does not silently downgrade to another metric - unsupported metric does not silently downgrade to another metric
- unsupported annual metric explains that the metric is not available for annual snapshots yet
- controlled error does not break page navigation or filter controls - controlled error does not break page navigation or filter controls
## Backend Behavior Requirements ## Backend Behavior Requirements
@@ -235,6 +302,24 @@ Expected rendering behavior:
- Do not expand annual generation logic inside request handling. - Do not expand annual generation logic inside request handling.
- Keep server scope limited to active supported scopes: `all`, `comunidad-hispana-01`, `comunidad-hispana-02`. - 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. - Do not surface Comunidad Hispana #03 in defaults, options or backend read scope.
- Do not add large new tables or a second ranking architecture for V1.1 metric expansion.
## Payload Adjustment Notes
- The existing `metric_value` field remains the primary sortable/display value for the active metric.
- Weekly and monthly responses should keep the existing totals payload and may add `kills_per_match` when the backend serves that metric.
- Annual responses should keep the same payload shape and only expand metrics when the snapshot schema already stores the required values safely.
- Client code should continue to rely on explicit `metric` and `snapshot_status` fields rather than inferring support from item contents alone.
## V1.1 Non-goals
- Elo/MMR
- public scoreboard as primary ranking source
- annual runtime full-year recomputation
- large new tables
- advanced charts
- authentication
- Comunidad Hispana #03
## Frontend Integration Guidance ## Frontend Integration Guidance

View File

@@ -1246,6 +1246,21 @@ h2 {
margin-top: 0; margin-top: 0;
} }
.ranking-form__note {
margin: 0;
padding: 0.8rem 1rem;
border: 1px solid rgba(159, 168, 141, 0.18);
border-radius: 14px;
background: rgba(15, 18, 13, 0.62);
color: var(--text-soft);
line-height: 1.65;
}
.ranking-form__note--warning {
border-color: rgba(210, 182, 118, 0.3);
color: #e2d7a9;
}
.ranking-select { .ranking-select {
width: 100%; width: 100%;
min-height: 52px; min-height: 52px;
@@ -1293,6 +1308,12 @@ h2 {
text-transform: uppercase; text-transform: uppercase;
} }
.ranking-meta-card--active {
border-color: rgba(183, 201, 125, 0.28);
background:
linear-gradient(180deg, rgba(36, 44, 31, 0.92), rgba(14, 18, 12, 0.98));
}
.ranking-table-shell { .ranking-table-shell {
overflow-x: auto; overflow-x: auto;
} }
@@ -1326,6 +1347,11 @@ h2 {
background: rgba(183, 201, 125, 0.05); background: rgba(183, 201, 125, 0.05);
} }
.ranking-table__metric {
color: var(--accent-warm);
font-weight: 700;
}
.ranking-player { .ranking-player {
display: grid; display: grid;
gap: 4px; gap: 4px;

View File

@@ -13,16 +13,41 @@ document.addEventListener("DOMContentLoaded", () => {
const metaNode = document.getElementById("ranking-meta"); const metaNode = document.getElementById("ranking-meta");
const tableNode = document.getElementById("ranking-table"); const tableNode = document.getElementById("ranking-table");
const tableBodyNode = document.getElementById("ranking-table-body"); const tableBodyNode = document.getElementById("ranking-table-body");
const metricHeadingNode = document.getElementById("ranking-metric-heading");
const emptyNode = document.getElementById("ranking-empty"); const emptyNode = document.getElementById("ranking-empty");
const filterNoteNode = document.getElementById("ranking-filter-note");
const currentYear = new Date().getUTCFullYear(); const currentYear = new Date().getUTCFullYear();
const annualMetric = "kills";
const defaultMetric = "kills";
const defaultLimit = "20";
const defaultTimeframe = "weekly";
const defaultServerId = "all";
const supportedMetrics = [
"kills",
"deaths",
"teamkills",
"matches_considered",
"kd_ratio",
"kills_per_match",
];
const supportedTimeframes = ["weekly", "monthly", "annual"];
const supportedServerIds = [
"all",
"comunidad-hispana-01",
"comunidad-hispana-02",
];
const supportedLimits = ["5", "10", "20", "50", "100"];
let isBackendOnline = false; let isBackendOnline = false;
if (yearInput) { if (yearInput) {
yearInput.value = String(currentYear); yearInput.value = String(currentYear);
} }
applyInitialUrlState();
toggleYearField(); toggleYearField();
syncMetricState();
setBackendState("Comprobando disponibilidad del backend", false); setBackendState("Comprobando disponibilidad del backend", false);
setRankingState("neutral", "Esperando filtros para cargar el ranking global."); setRankingState("neutral", "Esperando filtros para cargar el ranking global.");
clearRankingSurface(); clearRankingSurface();
@@ -31,6 +56,8 @@ document.addEventListener("DOMContentLoaded", () => {
if (timeframeSelect) { if (timeframeSelect) {
timeframeSelect.addEventListener("change", () => { timeframeSelect.addEventListener("change", () => {
toggleYearField(); toggleYearField();
syncMetricState();
updateUrlState();
void loadRanking(); void loadRanking();
}); });
} }
@@ -40,13 +67,25 @@ document.addEventListener("DOMContentLoaded", () => {
return; return;
} }
node.addEventListener("change", () => { node.addEventListener("change", () => {
syncMetricState();
updateUrlState();
void loadRanking(); void loadRanking();
}); });
}); });
if (yearInput) {
yearInput.addEventListener("change", () => {
updateUrlState();
if (String(timeframeSelect?.value || defaultTimeframe) === "annual") {
void loadRanking();
}
});
}
if (form) { if (form) {
form.addEventListener("submit", (event) => { form.addEventListener("submit", (event) => {
event.preventDefault(); event.preventDefault();
updateUrlState();
void loadRanking(); void loadRanking();
}); });
} }
@@ -69,6 +108,57 @@ document.addEventListener("DOMContentLoaded", () => {
stateNode.className = `stats-state stats-state--${state}`; stateNode.className = `stats-state stats-state--${state}`;
} }
function setFilterNote(message, tone = "neutral") {
if (!filterNoteNode) {
return;
}
filterNoteNode.textContent = message;
filterNoteNode.className = `ranking-form__note ranking-form__note--${tone}`;
}
function applyInitialUrlState() {
const params = new URLSearchParams(window.location.search);
const timeframe = params.get("timeframe");
const serverId = params.get("server_id") || params.get("server");
const metric = params.get("metric");
const limit = params.get("limit");
const year = params.get("year");
if (timeframeSelect) {
timeframeSelect.value = supportedTimeframes.includes(timeframe || "")
? String(timeframe)
: defaultTimeframe;
}
if (serverSelect) {
serverSelect.value = supportedServerIds.includes(serverId || "")
? String(serverId)
: defaultServerId;
}
if (metricSelect) {
metricSelect.value = supportedMetrics.includes(metric || "")
? String(metric)
: defaultMetric;
}
if (limitSelect) {
limitSelect.value = supportedLimits.includes(limit || "")
? String(limit)
: defaultLimit;
}
if (yearInput && year) {
const normalizedYear = Number.parseInt(year, 10);
if (Number.isFinite(normalizedYear) && normalizedYear > 0) {
yearInput.value = String(normalizedYear);
}
}
if (params.has("limit") && !supportedLimits.includes(limit || "")) {
setRankingState(
"warning",
"El limite del URL no es valido para esta interfaz. Se restauro el valor permitido por defecto.",
);
}
}
function toggleYearField() { function toggleYearField() {
const isAnnual = timeframeSelect?.value === "annual"; const isAnnual = timeframeSelect?.value === "annual";
if (yearWrap) { if (yearWrap) {
@@ -79,6 +169,47 @@ document.addEventListener("DOMContentLoaded", () => {
} }
} }
function syncMetricState() {
const isAnnual = timeframeSelect?.value === "annual";
if (!metricSelect) {
return;
}
Array.from(metricSelect.options).forEach((option) => {
option.disabled = isAnnual && option.value !== annualMetric;
});
if (isAnnual && metricSelect.value !== annualMetric) {
metricSelect.value = annualMetric;
}
if (isAnnual) {
setFilterNote(
"El ranking anual sigue limitado a kills porque solo esa metrica tiene lectura snapshot segura.",
"warning",
);
return;
}
setFilterNote(
"Ranking compara top globales. Para buscar un jugador concreto usa Stats.",
"neutral",
);
}
function updateUrlState() {
const searchParams = new URLSearchParams({
timeframe: String(timeframeSelect?.value || defaultTimeframe),
server_id: String(serverSelect?.value || defaultServerId),
metric: String(metricSelect?.value || defaultMetric),
limit: String(limitSelect?.value || defaultLimit),
});
if (searchParams.get("timeframe") === "annual") {
searchParams.set("year", String(yearInput?.value || currentYear));
}
window.history.replaceState({}, "", `?${searchParams.toString()}`);
}
function clearRankingSurface() { function clearRankingSurface() {
if (metaNode) { if (metaNode) {
metaNode.innerHTML = ""; metaNode.innerHTML = "";
@@ -115,7 +246,12 @@ document.addEventListener("DOMContentLoaded", () => {
throw new Error("Unexpected health payload"); throw new Error("Unexpected health payload");
} }
setBackendState("Backend operativo", true); setBackendState("Backend operativo", true);
setRankingState("neutral", "Backend disponible. Ajusta filtros o usa la lectura inicial."); if (!String(stateNode?.textContent || "").includes("limite del URL")) {
setRankingState(
"neutral",
"Backend disponible. Ajusta filtros o usa la lectura inicial.",
);
}
void loadRanking(); void loadRanking();
} catch (error) { } catch (error) {
console.warn("Ranking health check failed", error); console.warn("Ranking health check failed", error);
@@ -128,10 +264,10 @@ document.addEventListener("DOMContentLoaded", () => {
} }
async function loadRanking() { async function loadRanking() {
const timeframe = String(timeframeSelect?.value || "weekly"); const timeframe = String(timeframeSelect?.value || defaultTimeframe);
const serverId = String(serverSelect?.value || "all"); const serverId = String(serverSelect?.value || defaultServerId);
const metric = String(metricSelect?.value || "kills"); const metric = String(metricSelect?.value || defaultMetric);
const limit = String(limitSelect?.value || "20"); const limit = String(limitSelect?.value || defaultLimit);
if (!isBackendOnline) { if (!isBackendOnline) {
setRankingState("error", "Backend no disponible. El ranking queda en estado offline."); setRankingState("error", "Backend no disponible. El ranking queda en estado offline.");
@@ -168,7 +304,9 @@ document.addEventListener("DOMContentLoaded", () => {
const response = await fetch(`${backendBaseUrl}/api/ranking?${searchParams.toString()}`); const response = await fetch(`${backendBaseUrl}/api/ranking?${searchParams.toString()}`);
if (!response.ok) { if (!response.ok) {
const errorPayload = await safeParseJson(response); const errorPayload = await safeParseJson(response);
const errorMessage = String(errorPayload?.message || errorPayload?.detail || "").toLowerCase(); const errorMessage = String(
errorPayload?.message || errorPayload?.detail || "",
).toLowerCase();
handleRequestError(response.status, errorMessage, timeframe); handleRequestError(response.status, errorMessage, timeframe);
return; return;
} }
@@ -191,10 +329,28 @@ document.addEventListener("DOMContentLoaded", () => {
function handleRequestError(statusCode, errorMessage, timeframe) { function handleRequestError(statusCode, errorMessage, timeframe) {
const normalizedMessage = String(errorMessage || ""); const normalizedMessage = String(errorMessage || "");
if (statusCode === 400 && normalizedMessage.includes("metric")) { if (statusCode === 400 && normalizedMessage.includes("limit")) {
setRankingState("warning", "La metrica solicitada no esta soportada en V1."); setRankingState("warning", "El limite solicitado no es valido.");
renderEmptyState( renderEmptyState(
"Solo la metrica kills esta disponible en esta primera version del ranking global.", "Usa un limite permitido por la interfaz o por el backend. Esta vista admite Top 5, 10, 20, 50 y 100.",
);
return;
}
if (
statusCode === 400 &&
normalizedMessage.includes("metric") &&
normalizedMessage.includes("annual")
) {
setRankingState("warning", "El ranking anual solo admite kills por ahora.");
renderEmptyState(
"Las metricas extra estan disponibles en semanal y mensual. El ranking anual sigue limitado a kills mientras no existan snapshots seguros adicionales.",
);
return;
}
if (statusCode === 400 && normalizedMessage.includes("metric")) {
setRankingState("warning", "La metrica solicitada no esta soportada.");
renderEmptyState(
"Usa kills, deaths, teamkills, partidas jugadas, K/D o kills por partida.",
); );
return; return;
} }
@@ -208,11 +364,12 @@ document.addEventListener("DOMContentLoaded", () => {
renderEmptyState("Usa una ventana semanal, mensual o anual."); renderEmptyState("Usa una ventana semanal, mensual o anual.");
return; return;
} }
if (timeframe === "annual") { setRankingState(
setRankingState("error", "Error controlado al cargar el ranking anual."); "error",
} else { timeframe === "annual"
setRankingState("error", "Error controlado al cargar el ranking."); ? "Error controlado al cargar el ranking anual."
} : "Error controlado al cargar el ranking.",
);
renderEmptyState( renderEmptyState(
"La consulta devolvio un error controlado. Ajusta los filtros y vuelve a intentarlo.", "La consulta devolvio un error controlado. Ajusta los filtros y vuelve a intentarlo.",
); );
@@ -220,16 +377,20 @@ document.addEventListener("DOMContentLoaded", () => {
function renderRanking(data) { function renderRanking(data) {
const items = Array.isArray(data.items) ? data.items : []; const items = Array.isArray(data.items) ? data.items : [];
const timeframe = String(data.timeframe || "weekly"); const timeframe = String(data.timeframe || defaultTimeframe);
const serverId = String(data.server_id || "all"); const serverId = String(data.server_id || defaultServerId);
const metric = String(data.metric || "kills"); const metric = String(data.metric || defaultMetric);
const snapshotStatus = String(data.snapshot_status || "").toLowerCase(); const snapshotStatus = String(data.snapshot_status || "").toLowerCase();
if (titleNode) { if (titleNode) {
titleNode.textContent = titleNode.textContent =
timeframe === "annual" ? "Top anual activo" : "Tabla activa del alcance seleccionado"; timeframe === "annual"
? `Top anual por ${labelForMetric(metric)}`
: `Tabla activa por ${labelForMetric(metric)}`;
}
if (metricHeadingNode) {
metricHeadingNode.textContent = labelForMetric(metric);
} }
if (metaNode) { if (metaNode) {
metaNode.innerHTML = buildMetaMarkup(data); metaNode.innerHTML = buildMetaMarkup(data);
} }
@@ -259,11 +420,11 @@ document.addEventListener("DOMContentLoaded", () => {
setRankingState( setRankingState(
"ready", "ready",
`Ranking ${labelForTimeframe(timeframe)} listo para ${labelForServer(serverId)} por ${metric}.`, `${labelForMetric(metric)} listo en ${labelForTimeframe(timeframe)} para ${labelForServer(serverId)}.`,
); );
if (tableBodyNode) { if (tableBodyNode) {
tableBodyNode.innerHTML = items.map(renderRow).join(""); tableBodyNode.innerHTML = items.map((item) => renderRow(item, metric)).join("");
} }
if (tableNode) { if (tableNode) {
tableNode.hidden = false; tableNode.hidden = false;
@@ -275,23 +436,38 @@ document.addEventListener("DOMContentLoaded", () => {
function buildMetaMarkup(data) { function buildMetaMarkup(data) {
const source = data.source && typeof data.source === "object" ? data.source : {}; const source = data.source && typeof data.source === "object" ? data.source : {};
const parts = [ const timeframe = String(data.timeframe || defaultTimeframe);
`<article class="ranking-meta-card"><p>Servidor</p><strong>${escapeHtml(labelForServer(data.server_id))}</strong></article>`, const metric = String(data.metric || defaultMetric);
`<article class="ranking-meta-card"><p>Ventana</p><strong>${escapeHtml(labelForWindow(data))}</strong></article>`, const cards = [
`<article class="ranking-meta-card"><p>Fuente</p><strong>${escapeHtml(String(source.read_model || "No disponible"))}</strong></article>`, { label: "Periodo activo", value: labelForTimeframe(timeframe), active: true },
`<article class="ranking-meta-card"><p>Actualizado</p><strong>${escapeHtml(formatDateTime(source.generated_at))}</strong></article>`, { label: "Servidor activo", value: labelForServer(data.server_id), active: true },
{ label: "Metrica activa", value: labelForMetric(metric), active: true },
{ label: "Limite", value: `Top ${safeInt(data.limit, safeInt(defaultLimit, 20))}` },
{ label: "Ventana", value: labelForWindow(data) },
{ label: "Fuente", value: String(source.read_model || "No disponible") },
{ label: "Actualizado", value: formatDateTime(source.generated_at) },
]; ];
if (String(data.timeframe || "") === "annual") { if (timeframe === "annual") {
parts.push( cards.push({
`<article class="ranking-meta-card"><p>Snapshot</p><strong>${escapeHtml(String(data.snapshot_status || "missing"))}</strong></article>`, label: "Snapshot",
); value: String(data.snapshot_status || "missing"),
});
} }
return parts.join(""); return cards
.map(
(card) => `
<article class="ranking-meta-card${card.active ? " ranking-meta-card--active" : ""}">
<p>${escapeHtml(card.label)}</p>
<strong>${escapeHtml(String(card.value || "No disponible"))}</strong>
</article>
`,
)
.join("");
} }
function renderRow(item) { function renderRow(item, metric) {
return ` return `
<tr> <tr>
<td>#${safeInt(item.ranking_position, 0)}</td> <td>#${safeInt(item.ranking_position, 0)}</td>
@@ -301,11 +477,13 @@ document.addEventListener("DOMContentLoaded", () => {
<span>${escapeHtml(String(item.player_id || "Sin ID"))}</span> <span>${escapeHtml(String(item.player_id || "Sin ID"))}</span>
</div> </div>
</td> </td>
<td>${safeInt(item.metric_value, 0)}</td> <td class="ranking-table__metric">${formatMetricValue(item.metric_value, metric)}</td>
<td>${safeInt(item.matches_considered, 0)}</td> <td>${safeInt(item.kills, 0)}</td>
<td>${safeInt(item.deaths, 0)}</td> <td>${safeInt(item.deaths, 0)}</td>
<td>${safeInt(item.teamkills, 0)}</td> <td>${safeInt(item.teamkills, 0)}</td>
<td>${safeInt(item.matches_considered, 0)}</td>
<td>${safeDecimal(item.kd_ratio, 2, "0.00")}</td> <td>${safeDecimal(item.kd_ratio, 2, "0.00")}</td>
<td>${safeDecimal(item.kills_per_match, 2, "0.00")}</td>
</tr> </tr>
`; `;
} }
@@ -337,12 +515,31 @@ document.addEventListener("DOMContentLoaded", () => {
return "semanal"; return "semanal";
} }
function labelForMetric(metric) {
const labels = {
kills: "Kills",
deaths: "Deaths",
teamkills: "Teamkills",
matches_considered: "Partidas jugadas",
kd_ratio: "K/D",
kills_per_match: "Kills por partida",
};
return labels[metric] || "Kills";
}
function formatMetricValue(value, metric) {
if (metric === "kd_ratio" || metric === "kills_per_match") {
return safeDecimal(value, 2, "0.00");
}
return safeInt(value, 0).toLocaleString("es-ES");
}
function safeInt(value, fallback) { function safeInt(value, fallback) {
const parsed = Number(value); const parsed = Number(value);
if (!Number.isFinite(parsed)) { if (!Number.isFinite(parsed)) {
return fallback; return fallback;
} }
return parsed; return Math.trunc(parsed);
} }
function safeDecimal(value, maximumFractionDigits, fallback) { function safeDecimal(value, maximumFractionDigits, fallback) {

View File

@@ -22,9 +22,9 @@
<span class="hero__title-accent">Global</span> <span class="hero__title-accent">Global</span>
</h1> </h1>
<p class="hero__text"> <p class="hero__text">
Consulta los lideres publicos por kills con lectura RCON-first, cambia Consulta los lideres publicos por metrica con lectura RCON-first,
periodo y servidor sin salir de la pagina, y usa Stats solo cuando cambia periodo y servidor sin salir de la pagina, y deja Stats para
necesites revisar el rendimiento de un jugador concreto. revisar el rendimiento de un jugador concreto.
</p> </p>
<p class="status-chip status-chip--fallback" id="ranking-backend-state"> <p class="status-chip status-chip--fallback" id="ranking-backend-state">
Comprobando disponibilidad del backend Comprobando disponibilidad del backend
@@ -76,15 +76,22 @@
<label class="stats-search-form__label" for="ranking-metric"> <label class="stats-search-form__label" for="ranking-metric">
Metrica Metrica
<select class="ranking-select" id="ranking-metric" name="metric"> <select class="ranking-select" id="ranking-metric" name="metric">
<option value="kills">Kills</option> <option value="kills">Kills totales</option>
<option value="deaths">Deaths</option>
<option value="teamkills">Teamkills</option>
<option value="matches_considered">Partidas jugadas</option>
<option value="kd_ratio">K/D</option>
<option value="kills_per_match">Kills por partida</option>
</select> </select>
</label> </label>
<label class="stats-search-form__label" for="ranking-limit"> <label class="stats-search-form__label" for="ranking-limit">
Limite Limite
<select class="ranking-select" id="ranking-limit" name="limit"> <select class="ranking-select" id="ranking-limit" name="limit">
<option value="5">Top 5</option>
<option value="10">Top 10</option> <option value="10">Top 10</option>
<option value="20" selected>Top 20</option> <option value="20" selected>Top 20</option>
<option value="30">Top 30</option> <option value="50">Top 50</option>
<option value="100">Top 100</option>
</select> </select>
</label> </label>
<label class="stats-search-form__label" for="ranking-year" id="ranking-year-wrap" hidden> <label class="stats-search-form__label" for="ranking-year" id="ranking-year-wrap" hidden>
@@ -101,6 +108,9 @@
/> />
</label> </label>
</div> </div>
<p class="ranking-form__note" id="ranking-filter-note">
Ranking compara top globales. Para buscar un jugador concreto usa Stats.
</p>
<div class="hero__actions ranking-form__actions"> <div class="hero__actions ranking-form__actions">
<button class="discord-button" type="submit">Actualizar ranking</button> <button class="discord-button" type="submit">Actualizar ranking</button>
<a class="secondary-button secondary-button--compact" href="./stats.html"> <a class="secondary-button secondary-button--compact" href="./stats.html">
@@ -127,11 +137,13 @@
<tr> <tr>
<th>Pos.</th> <th>Pos.</th>
<th>Jugador</th> <th>Jugador</th>
<th id="ranking-metric-heading">Valor activo</th>
<th>Kills</th> <th>Kills</th>
<th>Partidas</th>
<th>Deaths</th> <th>Deaths</th>
<th>Teamkills</th> <th>Teamkills</th>
<th>Partidas</th>
<th>K/D</th> <th>K/D</th>
<th>Kills/partida</th>
</tr> </tr>
</thead> </thead>
<tbody id="ranking-table-body"></tbody> <tbody id="ranking-table-body"></tbody>

View File

@@ -32,6 +32,9 @@
<a class="secondary-button secondary-button--ghost" href="./index.html"> <a class="secondary-button secondary-button--ghost" href="./index.html">
Volver al inicio Volver al inicio
</a> </a>
<a class="secondary-button secondary-button--compact" href="./ranking.html">
Ranking
</a>
<a class="secondary-button secondary-button--compact" href="./historico.html"> <a class="secondary-button secondary-button--compact" href="./historico.html">
Historico Historico
</a> </a>

View File

@@ -49,9 +49,13 @@ function Assert-LastExitCode {
Assert-FileExists "frontend/stats.html" "Missing frontend/stats.html" Assert-FileExists "frontend/stats.html" "Missing frontend/stats.html"
Assert-FileExists "frontend/assets/js/stats.js" "Missing frontend/assets/js/stats.js" Assert-FileExists "frontend/assets/js/stats.js" "Missing frontend/assets/js/stats.js"
Assert-FileExists "frontend/ranking.html" "Missing frontend/ranking.html"
Assert-FileExists "frontend/assets/js/ranking.js" "Missing frontend/assets/js/ranking.js"
$statsHtml = Get-Content -Raw "frontend/stats.html" $statsHtml = Get-Content -Raw "frontend/stats.html"
$statsJs = Get-Content -Raw "frontend/assets/js/stats.js" $statsJs = Get-Content -Raw "frontend/assets/js/stats.js"
$rankingHtml = Get-Content -Raw "frontend/ranking.html"
$rankingJs = Get-Content -Raw "frontend/assets/js/ranking.js"
Assert-ContainsText $statsHtml 'id="stats-search-form"' ` Assert-ContainsText $statsHtml 'id="stats-search-form"' `
"Stats page no longer exposes the player search form." "Stats page no longer exposes the player search form."
@@ -71,11 +75,34 @@ Assert-ContainsText $statsHtml 'id="stats-annual-state"' `
"Stats page no longer exposes annual ranking state node." "Stats page no longer exposes annual ranking state node."
Assert-ContainsText $statsHtml 'id="stats-backend-state"' ` Assert-ContainsText $statsHtml 'id="stats-backend-state"' `
"Stats page no longer exposes backend state chip." "Stats page no longer exposes backend state chip."
Assert-ContainsText $statsHtml 'href="./ranking.html"' `
"Stats page should keep a direct link to ranking."
Assert-ContainsText $statsJs 'getElementById("stats-search-form")' ` Assert-ContainsText $statsJs 'getElementById("stats-search-form")' `
"Stats JS no longer sets up search form lookup." "Stats JS no longer sets up search form lookup."
Assert-ContainsText $statsJs "loadPlayerProfile(" ` Assert-ContainsText $statsJs "loadPlayerProfile(" `
"Stats JS no longer defines loadPlayerProfile." "Stats JS no longer defines loadPlayerProfile."
Assert-ContainsText $rankingHtml 'id="ranking-form"' `
"Ranking page no longer exposes the ranking filter form."
Assert-ContainsText $rankingHtml 'value="kills_per_match"' `
"Ranking page should expose the kills_per_match option."
Assert-ContainsText $rankingHtml 'value="matches_considered"' `
"Ranking page should expose the matches_considered option."
Assert-ContainsText $rankingHtml 'id="ranking-filter-note"' `
"Ranking page should expose the ranking filter guidance note."
Assert-ContainsText $rankingHtml 'id="ranking-metric-heading"' `
"Ranking page should expose the dynamic metric heading."
Assert-ContainsText $rankingJs "applyInitialUrlState" `
"Ranking JS should restore filter state from the URL."
Assert-ContainsText $rankingJs "history.replaceState" `
"Ranking JS should sync filter state into the URL."
Assert-ContainsText $rankingJs "El ranking anual sigue limitado a kills" `
"Ranking JS should explain the annual kills-only constraint."
Assert-ContainsText $rankingJs "El limite solicitado no es valido." `
"Ranking JS should expose a dedicated invalid-limit message."
Assert-ContainsText $rankingJs "/api/ranking" `
"Ranking frontend no longer targets the ranking endpoint."
Assert-ContainsText $statsJs "/api/stats/players/search" ` Assert-ContainsText $statsJs "/api/stats/players/search" `
"Stats frontend no longer targets the player search endpoint." "Stats frontend no longer targets the player search endpoint."
Assert-ContainsText $statsJs "/api/stats/rankings/annual" ` Assert-ContainsText $statsJs "/api/stats/rankings/annual" `
@@ -114,6 +141,10 @@ def require_str(value, message):
require(isinstance(value, str), message) require(isinstance(value, str), message)
def require_number(value, message):
require(isinstance(value, (int, float)), message)
health_status, health_payload = read_payload("/health") health_status, health_payload = read_payload("/health")
require(health_status == 200, "Route resolver /health should return 200.") require(health_status == 200, "Route resolver /health should return 200.")
require(health_payload.get("status") == "ok", "/health payload should be ok.") require(health_payload.get("status") == "ok", "/health payload should be ok.")
@@ -214,6 +245,36 @@ require(weekly_ranking_data.get("snapshot_status") == "ready", "Global ranking w
require(isinstance(weekly_ranking_data.get("items"), list), "Global ranking weekly items must be list.") 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.") require(isinstance(weekly_ranking_data.get("source"), dict), "Global ranking weekly should expose source metadata.")
weekly_deaths_status, weekly_deaths_payload = read_payload(
"/api/ranking?timeframe=weekly&server_id=all&metric=deaths&limit=20"
)
require(weekly_deaths_status == 200, "Global ranking weekly deaths route should return 200.")
require((weekly_deaths_payload.get("data") or {}).get("metric") == "deaths", "Global ranking weekly deaths metric should be preserved.")
weekly_teamkills_status, weekly_teamkills_payload = read_payload(
"/api/ranking?timeframe=weekly&server_id=all&metric=teamkills&limit=20"
)
require(weekly_teamkills_status == 200, "Global ranking weekly teamkills route should return 200.")
require((weekly_teamkills_payload.get("data") or {}).get("metric") == "teamkills", "Global ranking weekly teamkills metric should be preserved.")
weekly_matches_status, weekly_matches_payload = read_payload(
"/api/ranking?timeframe=weekly&server_id=all&metric=matches_considered&limit=20"
)
require(weekly_matches_status == 200, "Global ranking weekly matches_considered route should return 200.")
require((weekly_matches_payload.get("data") or {}).get("metric") == "matches_considered", "Global ranking weekly matches_considered metric should be preserved.")
weekly_kd_status, weekly_kd_payload = read_payload(
"/api/ranking?timeframe=weekly&server_id=all&metric=kd_ratio&limit=20"
)
require(weekly_kd_status == 200, "Global ranking weekly kd_ratio route should return 200.")
require((weekly_kd_payload.get("data") or {}).get("metric") == "kd_ratio", "Global ranking weekly kd_ratio metric should be preserved.")
weekly_kpm_status, weekly_kpm_payload = read_payload(
"/api/ranking?timeframe=weekly&server_id=all&metric=kills_per_match&limit=20"
)
require(weekly_kpm_status == 200, "Global ranking weekly kills_per_match route should return 200.")
require((weekly_kpm_payload.get("data") or {}).get("metric") == "kills_per_match", "Global ranking weekly kills_per_match metric should be preserved.")
monthly_ranking_status, monthly_ranking_payload = read_payload( monthly_ranking_status, monthly_ranking_payload = read_payload(
"/api/ranking?timeframe=monthly&server_id=comunidad-hispana-01&metric=kills&limit=20" "/api/ranking?timeframe=monthly&server_id=comunidad-hispana-01&metric=kills&limit=20"
) )
@@ -222,6 +283,18 @@ 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("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.") require(monthly_ranking_data.get("server_id") == "comunidad-hispana-01", "Global ranking monthly should preserve server_id.")
monthly_kd_status, monthly_kd_payload = read_payload(
"/api/ranking?timeframe=monthly&server_id=comunidad-hispana-01&metric=kd_ratio&limit=20"
)
require(monthly_kd_status == 200, "Global ranking monthly kd_ratio route should return 200.")
require((monthly_kd_payload.get("data") or {}).get("metric") == "kd_ratio", "Global ranking monthly kd_ratio metric should be preserved.")
monthly_kpm_status, monthly_kpm_payload = read_payload(
"/api/ranking?timeframe=monthly&server_id=comunidad-hispana-01&metric=kills_per_match&limit=20"
)
require(monthly_kpm_status == 200, "Global ranking monthly kills_per_match route should return 200.")
require((monthly_kpm_payload.get("data") or {}).get("metric") == "kills_per_match", "Global ranking monthly kills_per_match metric should be preserved.")
annual_ranking_status, annual_ranking_payload = read_payload( annual_ranking_status, annual_ranking_payload = read_payload(
f"/api/ranking?timeframe=annual&year={current_year}&server_id=all&metric=kills&limit=20" f"/api/ranking?timeframe=annual&year={current_year}&server_id=all&metric=kills&limit=20"
) )
@@ -232,6 +305,29 @@ require(annual_ranking_data.get("metric") == "kills", "Global ranking annual met
require(annual_ranking_data.get("snapshot_status") in {"ready", "missing"}, "Global ranking annual snapshot_status should be ready or missing.") 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.") require(isinstance(annual_ranking_data.get("items"), list), "Global ranking annual items must be list.")
for ranking_payload in [
weekly_ranking_payload,
weekly_deaths_payload,
weekly_teamkills_payload,
weekly_matches_payload,
weekly_kd_payload,
weekly_kpm_payload,
monthly_kd_payload,
monthly_kpm_payload,
annual_ranking_payload,
]:
ranking_data = ranking_payload.get("data") or {}
for item in ranking_data.get("items", []):
if item is None:
continue
require_int(item.get("ranking_position"), "Global ranking item ranking_position should be int.")
require_str(item.get("player_id"), "Global ranking item should include player_id.")
require_str(item.get("player_name"), "Global ranking item should include player_name.")
require_number(item.get("metric_value"), "Global ranking item metric_value should be numeric.")
require_int(item.get("matches_considered"), "Global ranking item matches_considered should be int.")
require_number(item.get("kd_ratio"), "Global ranking item kd_ratio should be numeric.")
require_number(item.get("kills_per_match"), "Global ranking item kills_per_match should be numeric.")
low_limit_ranking_status, low_limit_ranking_payload = read_payload( low_limit_ranking_status, low_limit_ranking_payload = read_payload(
"/api/ranking?timeframe=weekly&server_id=all&metric=kills&limit=3" "/api/ranking?timeframe=weekly&server_id=all&metric=kills&limit=3"
) )
@@ -244,10 +340,23 @@ high_limit_ranking_status, _ = read_payload(
require(high_limit_ranking_status == 400, "Global ranking with limit=101 should return 400.") require(high_limit_ranking_status == 400, "Global ranking with limit=101 should return 400.")
unsupported_metric_ranking_status, _ = read_payload( unsupported_metric_ranking_status, _ = read_payload(
"/api/ranking?timeframe=weekly&server_id=all&metric=deaths&limit=20" "/api/ranking?timeframe=weekly&server_id=all&metric=assists&limit=20"
) )
require(unsupported_metric_ranking_status == 400, "Global ranking with unsupported metric should return 400.") require(unsupported_metric_ranking_status == 400, "Global ranking with unsupported metric should return 400.")
annual_unsupported_metric_status, annual_unsupported_metric_payload = read_payload(
f"/api/ranking?timeframe=annual&year={current_year}&server_id=all&metric=deaths&limit=20"
)
require(annual_unsupported_metric_status == 400, "Global ranking annual with unsupported snapshot metric should return 400.")
require(
"annual" in str(
annual_unsupported_metric_payload.get("message")
or annual_unsupported_metric_payload.get("error")
or ""
).lower(),
"Annual unsupported metric error should mention annual snapshot support.",
)
unsupported_timeframe_ranking_status, _ = read_payload( unsupported_timeframe_ranking_status, _ = read_payload(
"/api/ranking?timeframe=seasonal&server_id=all&metric=kills&limit=20" "/api/ranking?timeframe=seasonal&server_id=all&metric=kills&limit=20"
) )
@@ -324,6 +433,21 @@ if ($backendAvailable) {
throw "Live global ranking weekly route should return status=ok." throw "Live global ranking weekly route should return status=ok."
} }
$rankingWeeklyDeathsPayload = Invoke-RestMethod -Uri "$backendBaseUrl/api/ranking?timeframe=weekly&server_id=all&metric=deaths&limit=20" -TimeoutSec 5
if ($rankingWeeklyDeathsPayload.status -ne "ok") {
throw "Live global ranking weekly deaths route should return status=ok."
}
$rankingWeeklyKdPayload = Invoke-RestMethod -Uri "$backendBaseUrl/api/ranking?timeframe=weekly&server_id=all&metric=kd_ratio&limit=20" -TimeoutSec 5
if ($rankingWeeklyKdPayload.status -ne "ok") {
throw "Live global ranking weekly kd_ratio route should return status=ok."
}
$rankingMonthlyKpmPayload = Invoke-RestMethod -Uri "$backendBaseUrl/api/ranking?timeframe=monthly&server_id=all&metric=kills_per_match&limit=20" -TimeoutSec 5
if ($rankingMonthlyKpmPayload.status -ne "ok") {
throw "Live global ranking monthly kills_per_match 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 $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") { if ($rankingAnnualPayload.status -ne "ok") {
throw "Live global ranking annual route should return status=ok." throw "Live global ranking annual route should return status=ok."
@@ -332,7 +456,22 @@ if ($backendAvailable) {
throw "Live global ranking annual should expose ready/missing snapshot status." 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" $rankingSupportedDeathsStatus = Get-HttpStatusCode -Url "$backendBaseUrl/api/ranking?timeframe=weekly&server_id=all&metric=deaths&limit=20"
if ($rankingSupportedDeathsStatus -ne 200) {
throw "Live global ranking weekly deaths should return HTTP 200."
}
$rankingInvalidMetricStatus = Get-HttpStatusCode -Url "$backendBaseUrl/api/ranking?timeframe=weekly&server_id=all&metric=assists&limit=20"
if ($rankingInvalidMetricStatus -ne 400) {
throw "Live global ranking with invalid metric should return HTTP 400."
}
$rankingUnsupportedAnnualMetricStatus = Get-HttpStatusCode -Url "$backendBaseUrl/api/ranking?timeframe=annual&year=$currentYear&server_id=all&metric=deaths&limit=20"
if ($rankingUnsupportedAnnualMetricStatus -ne 400) {
throw "Live global ranking annual with unsupported snapshot metric should return HTTP 400."
}
$rankingUnsupportedMetricStatus = Get-HttpStatusCode -Url "$backendBaseUrl/api/ranking?timeframe=weekly&server_id=all&metric=assists&limit=20"
if ($rankingUnsupportedMetricStatus -ne 400) { if ($rankingUnsupportedMetricStatus -ne 400) {
throw "Live global ranking with unsupported metric should return HTTP 400." throw "Live global ranking with unsupported metric should return HTTP 400."
} }