Add weekly historical leaderboard metrics

This commit is contained in:
devRaGonSa
2026-03-21 00:06:03 +01:00
parent 2ef0f9ebc7
commit 8a888f4cff
5 changed files with 202 additions and 5 deletions

View File

@@ -0,0 +1,85 @@
# TASK-037-historical-multi-metric-leaderboards-api
## Goal
Extender la capa histórica del backend para soportar varios rankings semanales por servidor, no solo top kills, de modo que la UI pueda mostrar pestañas con diferentes métricas relevantes.
## Context
La página histórica ya muestra un ranking semanal de kills, pero se quiere evolucionar hacia una sección con varias pestañas o vistas de ranking para el mismo rango temporal. Las métricas solicitadas inicialmente son:
- Top kills
- Top muertes
- Top número de partidas con más de 100 kills (a nivel de jugador)
- Top puntos de soporte
Para que la UI pueda hacerlo de forma limpia, primero hace falta una API histórica más flexible y consistente.
## Steps
1. Revisar la implementación actual del endpoint de `weekly-top-kills`.
2. Revisar el modelo histórico persistido y confirmar qué métricas están disponibles de forma fiable, especialmente:
- kills
- deaths
- support score / puntos de soporte
- kills por partida por jugador
3. Diseñar una estrategia de API para rankings históricos multitétrica. Puede ser:
- un endpoint genérico por métrica
- varios endpoints específicos
- o una solución equivalente siempre que sea clara y mantenible
4. Implementar soporte para estas métricas en la misma ventana temporal semanal:
- top kills
- top deaths
- top count of matches with kills >= 100 por jugador
- top support points
5. Asegurar que las queries:
- respetan el servidor seleccionado
- respetan el rango temporal semanal
- no mezclan datos entre servidores
- no devuelven duplicados por mala consolidación de identidad
6. Si alguna métrica requerida no estuviera siendo persistida todavía de forma válida, completar lo estrictamente necesario en la capa histórica para soportarla.
7. Documentar la nueva API en backend.
8. No crear todavía pestañas o cambios visuales en frontend en esta task.
9. Al completar la implementación:
- dejar el repositorio consistente
- hacer commit
- hacer push al remoto si el entorno lo permite
## Files to Read First
- AGENTS.md
- backend/README.md
- backend/app/routes.py
- backend/app/payloads.py
- backend/app/historical_storage.py
- backend/app/historical_models.py
- backend/app/historical_ingestion.py
- docs/historical-domain-model.md
- docs/historical-data-quality-notes.md
## Expected Files to Modify
- backend/app/routes.py
- backend/app/payloads.py
- backend/app/historical_storage.py
- backend/app/historical_models.py
- opcionalmente backend/app/historical_ingestion.py si hace falta completar la persistencia de alguna métrica necesaria
- backend/README.md
- opcionalmente nuevos módulos de query histórica si mejoran claridad
## Constraints
- No basar estas métricas en A2S.
- No crear UI en esta task.
- No depender de páginas externas de la comunidad.
- No romper el endpoint histórico actual salvo para mejorarlo o generalizarlo.
- No hacer cambios destructivos.
- Mantener el trabajo centrado en API histórica y consistencia de métricas.
## Validation
- Existen rankings históricos semanales para:
- kills
- muertes
- partidas con más de 100 kills por jugador
- puntos de soporte
- Los rankings funcionan por servidor.
- Los rankings respetan la ventana semanal definida por el proyecto.
- La documentación backend queda alineada.
- Los cambios quedan committeados y se hace push al remoto si el entorno lo permite.
## Change Budget
- Preferir menos de 7 archivos modificados o creados.
- Preferir menos de 260 líneas cambiadas.

View File

@@ -120,6 +120,7 @@ normaliza espacios y barras finales para mantener la comparacion con el header
- `GET /api/servers/history?limit=20` - `GET /api/servers/history?limit=20`
- `GET /api/servers/{id}/history?limit=20` - `GET /api/servers/{id}/history?limit=20`
- `GET /api/historical/weekly-top-kills?limit=10&server=comunidad-hispana-01` - `GET /api/historical/weekly-top-kills?limit=10&server=comunidad-hispana-01`
- `GET /api/historical/weekly-leaderboard?metric=kills&limit=10&server=comunidad-hispana-01`
- `GET /api/historical/recent-matches?limit=20&server=comunidad-hispana-01` - `GET /api/historical/recent-matches?limit=20&server=comunidad-hispana-01`
- `GET /api/historical/server-summary?server=comunidad-hispana-01` - `GET /api/historical/server-summary?server=comunidad-hispana-01`
- `GET /api/historical/player-profile?player=steam%3A76561198000000000` - `GET /api/historical/player-profile?player=steam%3A76561198000000000`
@@ -425,6 +426,7 @@ persistido por el colector. El parametro opcional `limit` acepta valores entre
La capa historica propia expone: La capa historica propia expone:
- `/api/historical/weekly-top-kills` - `/api/historical/weekly-top-kills`
- `/api/historical/weekly-leaderboard`
- `/api/historical/recent-matches` - `/api/historical/recent-matches`
- `/api/historical/server-summary` - `/api/historical/server-summary`
- `/api/historical/player-profile` - `/api/historical/player-profile`
@@ -442,6 +444,18 @@ partidas aun en curso ni filas historicas transitorias. El payload devuelve
servidor, rango temporal, jugador, kills semanales, posicion y numero de servidor, rango temporal, jugador, kills semanales, posicion y numero de
partidas consideradas. partidas consideradas.
`weekly-leaderboard` generaliza ese bloque para varias metricas semanales por
servidor usando la misma ventana movil de 7 dias y el mismo filtro de partidas
cerradas. Metricas soportadas:
- `kills`
- `deaths`
- `support`
- `matches_over_100_kills`
El endpoint legacy `/api/historical/weekly-top-kills` se conserva como alias
compatible para la metrica `kills`.
`recent-matches` devuelve cierres recientes por servidor con marcador, mapa y `recent-matches` devuelve cierres recientes por servidor con marcador, mapa y
conteo de jugadores. `server-summary` agrega volumen historico, jugadores conteo de jugadores. `server-summary` agrega volumen historico, jugadores
unicos, kills, mapas dominantes y rango temporal cubierto. `player-profile` unicos, kills, mapas dominantes y rango temporal cubierto. `player-profile`

View File

@@ -27,6 +27,14 @@ DEFAULT_HISTORICAL_SERVERS = (
) )
DEFAULT_WEEKLY_WINDOW_DAYS = 7 DEFAULT_WEEKLY_WINDOW_DAYS = 7
DEFAULT_REFRESH_OVERLAP_HOURS = 12 DEFAULT_REFRESH_OVERLAP_HOURS = 12
SUPPORTED_WEEKLY_LEADERBOARD_METRICS = frozenset(
{
"kills",
"deaths",
"support",
"matches_over_100_kills",
}
)
def initialize_historical_storage(*, db_path: Path | None = None) -> Path: def initialize_historical_storage(*, db_path: Path | None = None) -> Path:
@@ -763,16 +771,20 @@ def get_historical_player_profile(
} }
def list_weekly_top_kills( def list_weekly_leaderboard(
*, *,
limit: int = 10, limit: int = 10,
server_id: str | None = None, server_id: str | None = None,
metric: str = "kills",
db_path: Path | None = None, db_path: Path | None = None,
) -> dict[str, object]: ) -> dict[str, object]:
"""Return ranked weekly kill totals from persisted historical match stats.""" """Return ranked weekly leaderboard totals from persisted historical match stats."""
resolved_path = initialize_historical_storage(db_path=db_path) resolved_path = initialize_historical_storage(db_path=db_path)
window_end = datetime.now(timezone.utc) window_end = datetime.now(timezone.utc)
window_start = window_end - timedelta(days=DEFAULT_WEEKLY_WINDOW_DAYS) window_start = window_end - timedelta(days=DEFAULT_WEEKLY_WINDOW_DAYS)
normalized_metric = metric.strip() if isinstance(metric, str) else ""
if normalized_metric not in SUPPORTED_WEEKLY_LEADERBOARD_METRICS:
raise ValueError(f"Unsupported weekly leaderboard metric: {metric}")
where_clauses = [ where_clauses = [
"historical_matches.ended_at IS NOT NULL", "historical_matches.ended_at IS NOT NULL",
@@ -790,6 +802,16 @@ def list_weekly_top_kills(
) )
params.extend([normalized_server_id, normalized_server_id]) params.extend([normalized_server_id, normalized_server_id])
metric_sum_expression = {
"kills": "COALESCE(SUM(historical_player_match_stats.kills), 0)",
"deaths": "COALESCE(SUM(historical_player_match_stats.deaths), 0)",
"support": "COALESCE(SUM(historical_player_match_stats.support), 0)",
"matches_over_100_kills": (
"COALESCE(SUM(CASE WHEN COALESCE(historical_player_match_stats.kills, 0) >= 100 "
"THEN 1 ELSE 0 END), 0)"
),
}[normalized_metric]
with _connect(resolved_path) as connection: with _connect(resolved_path) as connection:
rows = connection.execute( rows = connection.execute(
f""" f"""
@@ -801,11 +823,11 @@ def list_weekly_top_kills(
historical_players.display_name AS player_name, historical_players.display_name AS player_name,
historical_players.steam_id, historical_players.steam_id,
COUNT(DISTINCT historical_matches.id) AS matches_count, COUNT(DISTINCT historical_matches.id) AS matches_count,
COALESCE(SUM(historical_player_match_stats.kills), 0) AS kills, {metric_sum_expression} AS metric_value,
ROW_NUMBER() OVER ( ROW_NUMBER() OVER (
PARTITION BY historical_servers.slug PARTITION BY historical_servers.slug
ORDER BY ORDER BY
COALESCE(SUM(historical_player_match_stats.kills), 0) DESC, {metric_sum_expression} DESC,
COUNT(DISTINCT historical_matches.id) ASC, COUNT(DISTINCT historical_matches.id) ASC,
historical_players.display_name ASC historical_players.display_name ASC
) AS ranking_position ) AS ranking_position
@@ -845,19 +867,48 @@ def list_weekly_top_kills(
"name": row["player_name"], "name": row["player_name"],
"steam_id": row["steam_id"], "steam_id": row["steam_id"],
}, },
"metric": normalized_metric,
"ranking_position": int(row["ranking_position"]), "ranking_position": int(row["ranking_position"]),
"weekly_kills": int(row["kills"] or 0), "metric_value": int(row["metric_value"] or 0),
"matches_considered": int(row["matches_count"] or 0), "matches_considered": int(row["matches_count"] or 0),
} }
) )
return { return {
"metric": normalized_metric,
"window_start": window_start.isoformat().replace("+00:00", "Z"), "window_start": window_start.isoformat().replace("+00:00", "Z"),
"window_end": window_end.isoformat().replace("+00:00", "Z"), "window_end": window_end.isoformat().replace("+00:00", "Z"),
"items": items, "items": items,
} }
def list_weekly_top_kills(
*,
limit: int = 10,
server_id: str | None = None,
db_path: Path | None = None,
) -> dict[str, object]:
"""Return ranked weekly kill totals from persisted historical match stats."""
result = list_weekly_leaderboard(
limit=limit,
server_id=server_id,
metric="kills",
db_path=db_path,
)
items = []
for item in result["items"]:
legacy_item = dict(item)
legacy_item["weekly_kills"] = legacy_item["metric_value"]
items.append(legacy_item)
return {
"metric": "kills",
"window_start": result["window_start"],
"window_end": result["window_end"],
"items": items,
}
def _connect(db_path: Path) -> sqlite3.Connection: def _connect(db_path: Path) -> sqlite3.Connection:
connection = sqlite3.connect(db_path) connection = sqlite3.connect(db_path)
connection.row_factory = sqlite3.Row connection.row_factory = sqlite3.Row

View File

@@ -10,6 +10,7 @@ from .historical_storage import (
get_historical_player_profile, get_historical_player_profile,
list_historical_server_summaries, list_historical_server_summaries,
list_recent_historical_matches, list_recent_historical_matches,
list_weekly_leaderboard,
list_weekly_top_kills, list_weekly_top_kills,
) )
from .normalizers import normalize_map_name from .normalizers import normalize_map_name
@@ -211,6 +212,36 @@ def build_weekly_top_kills_payload(
} }
def build_weekly_leaderboard_payload(
*,
limit: int = 10,
server_id: str | None = None,
metric: str = "kills",
) -> dict[str, object]:
"""Return one weekly historical leaderboard for the requested metric."""
result = list_weekly_leaderboard(limit=limit, server_id=server_id, metric=metric)
title_by_metric = {
"kills": "Top kills semanales por servidor",
"deaths": "Top muertes semanales por servidor",
"support": "Top puntos de soporte semanales por servidor",
"matches_over_100_kills": "Top partidas de 100+ kills semanales por servidor",
}
return {
"status": "ok",
"data": {
"title": title_by_metric.get(metric, "Ranking semanal por servidor"),
"context": "historical-weekly-leaderboard",
"metric": metric,
"summary_basis": "closed-matches-last-7-days",
"window_days": 7,
"window_start": result["window_start"],
"window_end": result["window_end"],
"limit": limit,
"items": result["items"],
},
}
def build_recent_historical_matches_payload( def build_recent_historical_matches_payload(
*, *,
limit: int = 20, limit: int = 20,

View File

@@ -18,6 +18,7 @@ from .payloads import (
build_server_latest_payload, build_server_latest_payload,
build_servers_payload, build_servers_payload,
build_trailer_payload, build_trailer_payload,
build_weekly_leaderboard_payload,
build_weekly_top_kills_payload, build_weekly_top_kills_payload,
) )
@@ -50,6 +51,21 @@ def resolve_get_payload(path: str) -> tuple[HTTPStatus | None, dict[str, object]
server_id = parse_qs(parsed.query).get("server", [None])[0] server_id = parse_qs(parsed.query).get("server", [None])[0]
return HTTPStatus.OK, build_weekly_top_kills_payload(limit=limit, server_id=server_id) return HTTPStatus.OK, build_weekly_top_kills_payload(limit=limit, server_id=server_id)
if parsed.path == "/api/historical/weekly-leaderboard":
limit = _parse_limit(parsed.query)
if limit is None:
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid limit parameter")
params = parse_qs(parsed.query)
server_id = params.get("server", [None])[0]
metric = params.get("metric", ["kills"])[0]
if metric not in {"kills", "deaths", "support", "matches_over_100_kills"}:
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid metric parameter")
return HTTPStatus.OK, build_weekly_leaderboard_payload(
limit=limit,
server_id=server_id,
metric=metric,
)
if parsed.path == "/api/historical/recent-matches": if parsed.path == "/api/historical/recent-matches":
limit = _parse_limit(parsed.query) limit = _parse_limit(parsed.query)
if limit is None: if limit is None: