Add weekly historical leaderboard metrics
This commit is contained in:
@@ -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.
|
||||||
@@ -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`
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user