diff --git a/ai/tasks/done/TASK-037-historical-multi-metric-leaderboards-api.md b/ai/tasks/done/TASK-037-historical-multi-metric-leaderboards-api.md new file mode 100644 index 0000000..9045090 --- /dev/null +++ b/ai/tasks/done/TASK-037-historical-multi-metric-leaderboards-api.md @@ -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. diff --git a/backend/README.md b/backend/README.md index 8132d9d..d0991a0 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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/{id}/history?limit=20` - `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/server-summary?server=comunidad-hispana-01` - `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: - `/api/historical/weekly-top-kills` +- `/api/historical/weekly-leaderboard` - `/api/historical/recent-matches` - `/api/historical/server-summary` - `/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 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 conteo de jugadores. `server-summary` agrega volumen historico, jugadores unicos, kills, mapas dominantes y rango temporal cubierto. `player-profile` diff --git a/backend/app/historical_storage.py b/backend/app/historical_storage.py index b392b9f..36a7b17 100644 --- a/backend/app/historical_storage.py +++ b/backend/app/historical_storage.py @@ -27,6 +27,14 @@ DEFAULT_HISTORICAL_SERVERS = ( ) DEFAULT_WEEKLY_WINDOW_DAYS = 7 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: @@ -763,16 +771,20 @@ def get_historical_player_profile( } -def list_weekly_top_kills( +def list_weekly_leaderboard( *, limit: int = 10, server_id: str | None = None, + metric: str = "kills", db_path: Path | None = None, ) -> 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) window_end = datetime.now(timezone.utc) 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 = [ "historical_matches.ended_at IS NOT NULL", @@ -790,6 +802,16 @@ def list_weekly_top_kills( ) 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: rows = connection.execute( f""" @@ -801,11 +823,11 @@ def list_weekly_top_kills( historical_players.display_name AS player_name, historical_players.steam_id, 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 ( PARTITION BY historical_servers.slug ORDER BY - COALESCE(SUM(historical_player_match_stats.kills), 0) DESC, + {metric_sum_expression} DESC, COUNT(DISTINCT historical_matches.id) ASC, historical_players.display_name ASC ) AS ranking_position @@ -845,19 +867,48 @@ def list_weekly_top_kills( "name": row["player_name"], "steam_id": row["steam_id"], }, + "metric": normalized_metric, "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), } ) return { + "metric": normalized_metric, "window_start": window_start.isoformat().replace("+00:00", "Z"), "window_end": window_end.isoformat().replace("+00:00", "Z"), "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: connection = sqlite3.connect(db_path) connection.row_factory = sqlite3.Row diff --git a/backend/app/payloads.py b/backend/app/payloads.py index 2faa5c4..c148d9e 100644 --- a/backend/app/payloads.py +++ b/backend/app/payloads.py @@ -10,6 +10,7 @@ from .historical_storage import ( get_historical_player_profile, list_historical_server_summaries, list_recent_historical_matches, + list_weekly_leaderboard, list_weekly_top_kills, ) 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( *, limit: int = 20, diff --git a/backend/app/routes.py b/backend/app/routes.py index 68c65d2..552364e 100644 --- a/backend/app/routes.py +++ b/backend/app/routes.py @@ -18,6 +18,7 @@ from .payloads import ( build_server_latest_payload, build_servers_payload, build_trailer_payload, + build_weekly_leaderboard_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] 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": limit = _parse_limit(parsed.query) if limit is None: