Process historical bootstrap and leaderboard tasks
This commit is contained in:
@@ -0,0 +1,70 @@
|
|||||||
|
# TASK-057-server-03-historical-bootstrap-and-snapshots
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Cargar histórico real para `comunidad-hispana-03` desde su fuente CRCON configurada, persistirlo en la base histórica del proyecto y generar los snapshots necesarios para que la UI deje de mostrarlo como pendiente de bootstrap.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
El servidor `comunidad-hispana-03` ya existe en la configuración del proyecto, pero actualmente la UI lo muestra con un estado honesto de “pendiente de histórico” porque todavía no se ha ejecutado bootstrap/backfill real para ese servidor. El objetivo de esta task es convertir ese servidor en un origen histórico real dentro del producto, dejándolo operativo igual que `#01` y `#02`.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
1. Revisar la configuración actual del servidor `comunidad-hispana-03`.
|
||||||
|
2. Confirmar que la fuente histórica configurada sigue siendo válida y accesible.
|
||||||
|
3. Ejecutar bootstrap/backfill real para `comunidad-hispana-03` con un enfoque operativo seguro y reanudable.
|
||||||
|
4. Persistir en SQLite:
|
||||||
|
- matches
|
||||||
|
- players
|
||||||
|
- stats por match
|
||||||
|
- progreso/backfill
|
||||||
|
5. Generar snapshots JSON para `comunidad-hispana-03`, al menos de:
|
||||||
|
- server-summary
|
||||||
|
- weekly-kills
|
||||||
|
- weekly-deaths
|
||||||
|
- weekly-matches-over-100-kills
|
||||||
|
- weekly-support
|
||||||
|
- recent-matches
|
||||||
|
6. Verificar que la UI deja de mostrar el estado de “pendiente de bootstrap” si ya existe histórico real.
|
||||||
|
7. Documentar el resultado del bootstrap de `#03`, incluyendo cobertura alcanzada si es posible.
|
||||||
|
8. No tocar la semántica de #01, #02 ni Totales salvo lo estrictamente necesario.
|
||||||
|
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/config.py
|
||||||
|
- backend/app/historical_ingestion.py
|
||||||
|
- backend/app/historical_storage.py
|
||||||
|
- backend/app/historical_snapshots.py
|
||||||
|
- backend/app/historical_snapshot_storage.py
|
||||||
|
- backend/app/historical_runner.py
|
||||||
|
- backend/app/payloads.py
|
||||||
|
- frontend/assets/js/historico.js
|
||||||
|
- docs/historical-coverage-report.md
|
||||||
|
|
||||||
|
## Expected Files to Modify
|
||||||
|
- backend/README.md
|
||||||
|
- backend/app/historical_ingestion.py
|
||||||
|
- backend/app/historical_snapshots.py
|
||||||
|
- backend/app/historical_snapshot_storage.py
|
||||||
|
- opcionalmente docs/historical-coverage-report.md
|
||||||
|
- y los snapshots generados bajo:
|
||||||
|
- backend/data/snapshots/comunidad-hispana-03/
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
- No usar A2S para esta carga histórica.
|
||||||
|
- No crear páginas nuevas.
|
||||||
|
- No romper #01, #02 ni Totales.
|
||||||
|
- No hacer cambios destructivos.
|
||||||
|
- Mantener el trabajo centrado en dejar #03 con histórico real y snapshots útiles.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
- `comunidad-hispana-03` deja de aparecer como pendiente de bootstrap si hay histórico suficiente.
|
||||||
|
- Existen snapshots JSON no vacíos para `#03` cuando la fuente devuelve datos.
|
||||||
|
- El histórico bruto de `#03` queda persistido en SQLite.
|
||||||
|
- Los cambios quedan committeados y se hace push si el entorno lo permite.
|
||||||
|
|
||||||
|
## Change Budget
|
||||||
|
- Preferir menos de 6 archivos modificados o creados, sin contar los snapshots generados.
|
||||||
|
- Preferir menos de 240 líneas cambiadas.
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# TASK-058-monthly-historical-leaderboards-api
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Ampliar la API histórica de leaderboards para soportar una dimensión mensual además de la semanal, reutilizando las mismas métricas actuales del producto.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
La página histórica ya muestra tops semanales por métrica:
|
||||||
|
- kills
|
||||||
|
- muertes
|
||||||
|
- partidas con más de 100 kills
|
||||||
|
- soporte
|
||||||
|
|
||||||
|
Ahora se quiere añadir una segunda dimensión temporal: mensual. La API debe poder servir snapshots o payloads equivalentes para el periodo mensual con la misma claridad que ya existe en semanal.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
1. Revisar la implementación actual de tops semanales y su semántica temporal.
|
||||||
|
2. Diseñar una dimensión mensual coherente para leaderboards históricos.
|
||||||
|
3. Definir la política temporal mensual, idealmente basada en mes natural cerrado o en el mes actual si la política del proyecto lo requiere, dejándolo claro.
|
||||||
|
4. Implementar soporte backend para leaderboards mensuales con estas métricas:
|
||||||
|
- kills
|
||||||
|
- muertes
|
||||||
|
- partidas con más de 100 kills
|
||||||
|
- soporte
|
||||||
|
5. Asegurar que la API exponga metadatos claros del rango temporal real usado.
|
||||||
|
6. Integrar el soporte mensual en la capa de snapshots JSON en disco.
|
||||||
|
7. Mantener compatibilidad con la capa semanal existente.
|
||||||
|
8. Documentar la nueva capacidad en backend.
|
||||||
|
9. No crear todavía la pestaña visual en esta task si no es imprescindible.
|
||||||
|
10. 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/payloads.py
|
||||||
|
- backend/app/routes.py
|
||||||
|
- backend/app/historical_storage.py
|
||||||
|
- backend/app/historical_snapshots.py
|
||||||
|
- backend/app/historical_snapshot_storage.py
|
||||||
|
- frontend/assets/js/historico.js
|
||||||
|
|
||||||
|
## Expected Files to Modify
|
||||||
|
- backend/app/payloads.py
|
||||||
|
- backend/app/routes.py
|
||||||
|
- backend/app/historical_storage.py
|
||||||
|
- backend/app/historical_snapshots.py
|
||||||
|
- backend/app/historical_snapshot_storage.py
|
||||||
|
- backend/README.md
|
||||||
|
- opcionalmente documentación técnica adicional si hace falta aclarar la política mensual
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
- No romper tops semanales existentes.
|
||||||
|
- No usar A2S para estos tops históricos.
|
||||||
|
- No hacer cambios destructivos.
|
||||||
|
- Mantener el trabajo centrado en leaderboards mensuales y metadatos temporales claros.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
- Existen leaderboards mensuales para las métricas soportadas.
|
||||||
|
- La API distingue correctamente entre semanal y mensual.
|
||||||
|
- Los snapshots mensuales se generan y persisten correctamente.
|
||||||
|
- Los cambios quedan committeados y se hace push si el entorno lo permite.
|
||||||
|
|
||||||
|
## Change Budget
|
||||||
|
- Preferir menos de 6 archivos modificados o creados.
|
||||||
|
- Preferir menos de 240 líneas cambiadas.
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# TASK-059-historical-leaderboards-timeframe-tabs-ui
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Ampliar la sección de tops de la página histórica para permitir alternar entre rankings semanales y mensuales dentro de la misma zona visual, manteniendo también las pestañas por métrica.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
La sección de tops ya tiene pestañas por métrica:
|
||||||
|
- Top kills
|
||||||
|
- Top muertes
|
||||||
|
- Partidas 100+ kills
|
||||||
|
- Soporte
|
||||||
|
|
||||||
|
Ahora se quiere añadir una segunda capa de navegación temporal dentro de esa misma sección para consultar:
|
||||||
|
- semanal
|
||||||
|
- mensual
|
||||||
|
|
||||||
|
La experiencia debe seguir siendo clara, rápida y coherente con el estilo actual de la página.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
1. Revisar la UI actual de la sección de leaderboards.
|
||||||
|
2. Revisar la nueva API mensual y cómo convive con la semanal.
|
||||||
|
3. Diseñar una navegación clara para el marco temporal, por ejemplo:
|
||||||
|
- Semanal
|
||||||
|
- Mensual
|
||||||
|
4. Mantener las pestañas de métricas ya existentes dentro de ese marco temporal.
|
||||||
|
5. Hacer que la UI pueda alternar entre:
|
||||||
|
- semanal + kills
|
||||||
|
- semanal + muertes
|
||||||
|
- semanal + 100+ kills
|
||||||
|
- semanal + soporte
|
||||||
|
- mensual + kills
|
||||||
|
- mensual + muertes
|
||||||
|
- mensual + 100+ kills
|
||||||
|
- mensual + soporte
|
||||||
|
6. Mostrar el rango temporal real usado de forma clara y natural.
|
||||||
|
7. Mantener estados de loading, empty y error.
|
||||||
|
8. Mantener el rendimiento percibido razonable y compatible con la estrategia actual de snapshots.
|
||||||
|
9. No crear una nueva página; la ampliación debe quedar dentro de la sección actual de tops.
|
||||||
|
10. 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
|
||||||
|
- frontend/historico.html
|
||||||
|
- frontend/assets/js/historico.js
|
||||||
|
- frontend/assets/css/historico.css
|
||||||
|
- backend/app/routes.py
|
||||||
|
- backend/app/payloads.py
|
||||||
|
- backend/README.md
|
||||||
|
|
||||||
|
## Expected Files to Modify
|
||||||
|
- frontend/historico.html
|
||||||
|
- frontend/assets/js/historico.js
|
||||||
|
- frontend/assets/css/historico.css
|
||||||
|
- opcionalmente backend docs mínimas si cambia el contrato visible de uso
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
- No romper el flujo actual semanal.
|
||||||
|
- No introducir frameworks nuevos.
|
||||||
|
- No crear páginas nuevas.
|
||||||
|
- No hacer cambios destructivos.
|
||||||
|
- Mantener el trabajo centrado en la navegación temporal dentro de los tops.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
- La sección de tops permite alternar entre semanal y mensual.
|
||||||
|
- Siguen funcionando las métricas actuales.
|
||||||
|
- El rango temporal mostrado es claro.
|
||||||
|
- La UI sigue siendo coherente con el resto de la página histórica.
|
||||||
|
- Los cambios quedan committeados y se hace push si el entorno lo permite.
|
||||||
|
|
||||||
|
## Change Budget
|
||||||
|
- Preferir menos de 5 archivos modificados.
|
||||||
|
- Preferir menos de 220 líneas cambiadas.
|
||||||
@@ -127,10 +127,12 @@ normaliza espacios y barras finales para mantener la comparacion con el header
|
|||||||
- `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/weekly-leaderboard?metric=kills&limit=10&server=comunidad-hispana-01`
|
||||||
|
- `GET /api/historical/leaderboard?timeframe=monthly&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/snapshots/server-summary?server=comunidad-hispana-01`
|
- `GET /api/historical/snapshots/server-summary?server=comunidad-hispana-01`
|
||||||
- `GET /api/historical/snapshots/weekly-leaderboard?metric=kills&limit=10&server=comunidad-hispana-01`
|
- `GET /api/historical/snapshots/weekly-leaderboard?metric=kills&limit=10&server=comunidad-hispana-01`
|
||||||
|
- `GET /api/historical/snapshots/leaderboard?timeframe=monthly&metric=kills&limit=10&server=comunidad-hispana-01`
|
||||||
- `GET /api/historical/snapshots/recent-matches?limit=6&server=comunidad-hispana-01`
|
- `GET /api/historical/snapshots/recent-matches?limit=6&server=comunidad-hispana-01`
|
||||||
- `GET /api/historical/player-profile?player=steam%3A76561198000000000`
|
- `GET /api/historical/player-profile?player=steam%3A76561198000000000`
|
||||||
|
|
||||||
@@ -226,6 +228,7 @@ bruto. Esta capa esta preparada para guardar:
|
|||||||
|
|
||||||
- `server-summary`
|
- `server-summary`
|
||||||
- `weekly-leaderboard` con metricas `kills`, `deaths`, `support` y `matches_over_100_kills`
|
- `weekly-leaderboard` con metricas `kills`, `deaths`, `support` y `matches_over_100_kills`
|
||||||
|
- `monthly-leaderboard` con las mismas metricas semanticas
|
||||||
- `recent-matches`
|
- `recent-matches`
|
||||||
|
|
||||||
Por defecto se escriben bajo:
|
Por defecto se escriben bajo:
|
||||||
@@ -477,10 +480,12 @@ La capa historica propia expone:
|
|||||||
|
|
||||||
- `/api/historical/weekly-top-kills`
|
- `/api/historical/weekly-top-kills`
|
||||||
- `/api/historical/weekly-leaderboard`
|
- `/api/historical/weekly-leaderboard`
|
||||||
|
- `/api/historical/leaderboard`
|
||||||
- `/api/historical/recent-matches`
|
- `/api/historical/recent-matches`
|
||||||
- `/api/historical/server-summary`
|
- `/api/historical/server-summary`
|
||||||
- `/api/historical/snapshots/server-summary`
|
- `/api/historical/snapshots/server-summary`
|
||||||
- `/api/historical/snapshots/weekly-leaderboard`
|
- `/api/historical/snapshots/weekly-leaderboard`
|
||||||
|
- `/api/historical/snapshots/leaderboard`
|
||||||
- `/api/historical/snapshots/recent-matches`
|
- `/api/historical/snapshots/recent-matches`
|
||||||
- `/api/historical/player-profile`
|
- `/api/historical/player-profile`
|
||||||
|
|
||||||
@@ -514,6 +519,13 @@ Metricas soportadas:
|
|||||||
El endpoint legacy `/api/historical/weekly-top-kills` se conserva como alias
|
El endpoint legacy `/api/historical/weekly-top-kills` se conserva como alias
|
||||||
compatible para la metrica `kills`.
|
compatible para la metrica `kills`.
|
||||||
|
|
||||||
|
`/api/historical/leaderboard` y `/api/historical/snapshots/leaderboard`
|
||||||
|
generalizan ese mismo contrato con `timeframe=weekly|monthly`. Para `monthly`,
|
||||||
|
la politica temporal usa el mes natural UTC en curso y hace fallback al mes
|
||||||
|
cerrado anterior solo cuando el mes actual todavia no tiene ningun cierre.
|
||||||
|
Ambas variantes exponen el rango real usado mediante `window_start`,
|
||||||
|
`window_end`, `window_kind`, `window_label` y `selection_reason`.
|
||||||
|
|
||||||
`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`
|
||||||
@@ -558,6 +570,10 @@ payload ya persistido sin recalcularlo. `/api/historical/snapshots/recent-matche
|
|||||||
devuelve `items` de cierres recientes ya preparados y tambien acepta `limit`
|
devuelve `items` de cierres recientes ya preparados y tambien acepta `limit`
|
||||||
para servir solo una parte del snapshot persistido.
|
para servir solo una parte del snapshot persistido.
|
||||||
|
|
||||||
|
La misma capa de snapshots guarda tambien `monthly-leaderboard` por servidor y
|
||||||
|
por agregado `all-servers`, con archivos como `monthly-kills.json` y
|
||||||
|
`monthly-support.json`.
|
||||||
|
|
||||||
## Ingesta historica CRCON
|
## Ingesta historica CRCON
|
||||||
|
|
||||||
La ingesta historica no usa A2S ni scraping del HTML de `/games`. Consume la
|
La ingesta historica no usa A2S ni scraping del HTML de `/games`. Consume la
|
||||||
@@ -639,6 +655,7 @@ correcto. Por defecto:
|
|||||||
- prewarmea en cada ciclo:
|
- prewarmea en cada ciclo:
|
||||||
- `server-summary` para `comunidad-hispana-01`, `comunidad-hispana-02`, `comunidad-hispana-03` y `all-servers`
|
- `server-summary` para `comunidad-hispana-01`, `comunidad-hispana-02`, `comunidad-hispana-03` y `all-servers`
|
||||||
- `weekly-leaderboard` de la metrica por defecto `kills` para esos mismos alcances
|
- `weekly-leaderboard` de la metrica por defecto `kills` para esos mismos alcances
|
||||||
|
- `monthly-leaderboard` de la metrica por defecto `kills` para esos mismos alcances
|
||||||
- `recent-matches` para esos mismos alcances
|
- `recent-matches` para esos mismos alcances
|
||||||
- recompone la matriz completa de snapshots cada `4` ciclos para mantener el resto de metricas al dia sin penalizar todos los refresh
|
- recompone la matriz completa de snapshots cada `4` ciclos para mantener el resto de metricas al dia sin penalizar todos los refresh
|
||||||
- reintenta hasta `2` veces tras un fallo
|
- reintenta hasta `2` veces tras un fallo
|
||||||
@@ -648,6 +665,7 @@ correcto. Por defecto:
|
|||||||
- persiste por servidor:
|
- persiste por servidor:
|
||||||
- `server-summary`
|
- `server-summary`
|
||||||
- `weekly-leaderboard` para `kills`, `deaths`, `support` y `matches_over_100_kills`
|
- `weekly-leaderboard` para `kills`, `deaths`, `support` y `matches_over_100_kills`
|
||||||
|
- `monthly-leaderboard` para `kills`, `deaths`, `support` y `matches_over_100_kills`
|
||||||
- `recent-matches`
|
- `recent-matches`
|
||||||
|
|
||||||
Flags utiles del runner:
|
Flags utiles del runner:
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import sqlite3
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -58,8 +57,6 @@ def persist_historical_snapshot(
|
|||||||
if _should_preserve_existing_snapshot(
|
if _should_preserve_existing_snapshot(
|
||||||
incoming_payload=payload,
|
incoming_payload=payload,
|
||||||
snapshot_type=snapshot_type,
|
snapshot_type=snapshot_type,
|
||||||
server_key=normalized_server_key,
|
|
||||||
db_path=db_path,
|
|
||||||
existing_document=existing_document,
|
existing_document=existing_document,
|
||||||
):
|
):
|
||||||
preserved_payload = existing_document.get("payload") if existing_document else payload
|
preserved_payload = existing_document.get("payload") if existing_document else payload
|
||||||
@@ -222,8 +219,6 @@ def _should_preserve_existing_snapshot(
|
|||||||
*,
|
*,
|
||||||
incoming_payload: dict[str, object] | list[object],
|
incoming_payload: dict[str, object] | list[object],
|
||||||
snapshot_type: str,
|
snapshot_type: str,
|
||||||
server_key: str,
|
|
||||||
db_path: Path | None,
|
|
||||||
existing_document: dict[str, object] | None,
|
existing_document: dict[str, object] | None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
if not _is_effectively_empty_snapshot_payload(snapshot_type, incoming_payload):
|
if not _is_effectively_empty_snapshot_payload(snapshot_type, incoming_payload):
|
||||||
@@ -233,7 +228,7 @@ def _should_preserve_existing_snapshot(
|
|||||||
existing_document.get("payload"),
|
existing_document.get("payload"),
|
||||||
):
|
):
|
||||||
return True
|
return True
|
||||||
return _historical_data_exists(server_key=server_key, db_path=db_path)
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _is_effectively_empty_snapshot_payload(
|
def _is_effectively_empty_snapshot_payload(
|
||||||
@@ -254,40 +249,11 @@ def _is_effectively_empty_snapshot_payload(
|
|||||||
items = payload.get("items")
|
items = payload.get("items")
|
||||||
return not isinstance(items, list) or len(items) == 0
|
return not isinstance(items, list) or len(items) == 0
|
||||||
|
|
||||||
if snapshot_type == "weekly-leaderboard":
|
if snapshot_type in {"weekly-leaderboard", "monthly-leaderboard"}:
|
||||||
items = payload.get("items")
|
items = payload.get("items")
|
||||||
return not isinstance(items, list) or len(items) == 0
|
return not isinstance(items, list) or len(items) == 0
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _historical_data_exists(*, server_key: str, db_path: Path | None) -> bool:
|
|
||||||
resolved_db_path = initialize_historical_storage(db_path=db_path or get_storage_path())
|
|
||||||
where_clause = ""
|
|
||||||
params: list[object] = []
|
|
||||||
if server_key != "all-servers":
|
|
||||||
where_clause = "WHERE historical_servers.slug = ?"
|
|
||||||
params.append(server_key)
|
|
||||||
|
|
||||||
connection = sqlite3.connect(resolved_db_path)
|
|
||||||
connection.row_factory = sqlite3.Row
|
|
||||||
try:
|
|
||||||
row = connection.execute(
|
|
||||||
f"""
|
|
||||||
SELECT COUNT(*) AS matches_count
|
|
||||||
FROM historical_matches
|
|
||||||
INNER JOIN historical_servers
|
|
||||||
ON historical_servers.id = historical_matches.historical_server_id
|
|
||||||
{where_clause}
|
|
||||||
""",
|
|
||||||
params,
|
|
||||||
).fetchone()
|
|
||||||
finally:
|
|
||||||
connection.close()
|
|
||||||
|
|
||||||
return bool(row and int(row["matches_count"] or 0) > 0)
|
|
||||||
|
|
||||||
|
|
||||||
def _read_snapshot_document(snapshot_path: Path) -> dict[str, object] | None:
|
def _read_snapshot_document(snapshot_path: Path) -> dict[str, object] | None:
|
||||||
if not snapshot_path.exists():
|
if not snapshot_path.exists():
|
||||||
return None
|
return None
|
||||||
@@ -318,6 +284,9 @@ def _build_snapshot_filename(*, snapshot_type: str, metric: str | None) -> str:
|
|||||||
if snapshot_type == "weekly-leaderboard":
|
if snapshot_type == "weekly-leaderboard":
|
||||||
metric_suffix = "matches-over-100-kills" if metric == "matches_over_100_kills" else _slugify(metric or "unknown")
|
metric_suffix = "matches-over-100-kills" if metric == "matches_over_100_kills" else _slugify(metric or "unknown")
|
||||||
return f"weekly-{metric_suffix}.json"
|
return f"weekly-{metric_suffix}.json"
|
||||||
|
if snapshot_type == "monthly-leaderboard":
|
||||||
|
metric_suffix = "matches-over-100-kills" if metric == "matches_over_100_kills" else _slugify(metric or "unknown")
|
||||||
|
return f"monthly-{metric_suffix}.json"
|
||||||
metric_suffix = _slugify(metric or "")
|
metric_suffix = _slugify(metric or "")
|
||||||
base_name = _slugify(snapshot_type)
|
base_name = _slugify(snapshot_type)
|
||||||
return f"{base_name}-{metric_suffix}.json" if metric_suffix else f"{base_name}.json"
|
return f"{base_name}-{metric_suffix}.json" if metric_suffix else f"{base_name}.json"
|
||||||
|
|||||||
@@ -9,18 +9,21 @@ from .historical_storage import (
|
|||||||
ALL_SERVERS_SLUG,
|
ALL_SERVERS_SLUG,
|
||||||
list_historical_server_summaries,
|
list_historical_server_summaries,
|
||||||
list_historical_servers,
|
list_historical_servers,
|
||||||
|
list_monthly_leaderboard,
|
||||||
list_recent_historical_matches,
|
list_recent_historical_matches,
|
||||||
list_weekly_leaderboard,
|
list_weekly_leaderboard,
|
||||||
)
|
)
|
||||||
|
|
||||||
SNAPSHOT_TYPE_SERVER_SUMMARY = "server-summary"
|
SNAPSHOT_TYPE_SERVER_SUMMARY = "server-summary"
|
||||||
SNAPSHOT_TYPE_WEEKLY_LEADERBOARD = "weekly-leaderboard"
|
SNAPSHOT_TYPE_WEEKLY_LEADERBOARD = "weekly-leaderboard"
|
||||||
|
SNAPSHOT_TYPE_MONTHLY_LEADERBOARD = "monthly-leaderboard"
|
||||||
SNAPSHOT_TYPE_RECENT_MATCHES = "recent-matches"
|
SNAPSHOT_TYPE_RECENT_MATCHES = "recent-matches"
|
||||||
|
|
||||||
SUPPORTED_SNAPSHOT_TYPES = frozenset(
|
SUPPORTED_SNAPSHOT_TYPES = frozenset(
|
||||||
{
|
{
|
||||||
SNAPSHOT_TYPE_SERVER_SUMMARY,
|
SNAPSHOT_TYPE_SERVER_SUMMARY,
|
||||||
SNAPSHOT_TYPE_WEEKLY_LEADERBOARD,
|
SNAPSHOT_TYPE_WEEKLY_LEADERBOARD,
|
||||||
|
SNAPSHOT_TYPE_MONTHLY_LEADERBOARD,
|
||||||
SNAPSHOT_TYPE_RECENT_MATCHES,
|
SNAPSHOT_TYPE_RECENT_MATCHES,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -49,6 +52,7 @@ SNAPSHOT_LEADERBOARD_METRICS = (
|
|||||||
|
|
||||||
DEFAULT_SNAPSHOT_WINDOW = "all-time"
|
DEFAULT_SNAPSHOT_WINDOW = "all-time"
|
||||||
DEFAULT_WEEKLY_SNAPSHOT_WINDOW = "7d"
|
DEFAULT_WEEKLY_SNAPSHOT_WINDOW = "7d"
|
||||||
|
DEFAULT_MONTHLY_SNAPSHOT_WINDOW = "month"
|
||||||
DEFAULT_WEEKLY_LEADERBOARD_LIMIT = 10
|
DEFAULT_WEEKLY_LEADERBOARD_LIMIT = 10
|
||||||
DEFAULT_RECENT_MATCHES_LIMIT = 20
|
DEFAULT_RECENT_MATCHES_LIMIT = 20
|
||||||
|
|
||||||
@@ -62,13 +66,18 @@ def validate_snapshot_identity(
|
|||||||
if snapshot_type not in SUPPORTED_SNAPSHOT_TYPES:
|
if snapshot_type not in SUPPORTED_SNAPSHOT_TYPES:
|
||||||
raise ValueError(f"Unsupported historical snapshot type: {snapshot_type}")
|
raise ValueError(f"Unsupported historical snapshot type: {snapshot_type}")
|
||||||
|
|
||||||
if snapshot_type == SNAPSHOT_TYPE_WEEKLY_LEADERBOARD:
|
if snapshot_type in {
|
||||||
|
SNAPSHOT_TYPE_WEEKLY_LEADERBOARD,
|
||||||
|
SNAPSHOT_TYPE_MONTHLY_LEADERBOARD,
|
||||||
|
}:
|
||||||
if metric not in SUPPORTED_LEADERBOARD_METRICS:
|
if metric not in SUPPORTED_LEADERBOARD_METRICS:
|
||||||
raise ValueError(f"Unsupported historical snapshot metric: {metric}")
|
raise ValueError(f"Unsupported historical snapshot metric: {metric}")
|
||||||
return
|
return
|
||||||
|
|
||||||
if metric is not None:
|
if metric is not None:
|
||||||
raise ValueError(f"Metric is only supported for {SNAPSHOT_TYPE_WEEKLY_LEADERBOARD}.")
|
raise ValueError(
|
||||||
|
"Metric is only supported for weekly-leaderboard and monthly-leaderboard."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def list_snapshot_server_keys(*, db_path: Path | None = None) -> list[str]:
|
def list_snapshot_server_keys(*, db_path: Path | None = None) -> list[str]:
|
||||||
@@ -104,6 +113,15 @@ def build_historical_server_snapshots(
|
|||||||
db_path=db_path,
|
db_path=db_path,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
snapshots.append(
|
||||||
|
_build_monthly_leaderboard_snapshot(
|
||||||
|
server_key,
|
||||||
|
metric,
|
||||||
|
generated_at_value,
|
||||||
|
limit=leaderboard_limit,
|
||||||
|
db_path=db_path,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
snapshots.append(
|
snapshots.append(
|
||||||
_build_recent_matches_snapshot(
|
_build_recent_matches_snapshot(
|
||||||
@@ -141,6 +159,15 @@ def build_priority_historical_snapshots(
|
|||||||
db_path=db_path,
|
db_path=db_path,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
snapshots.append(
|
||||||
|
_build_monthly_leaderboard_snapshot(
|
||||||
|
server_key,
|
||||||
|
metric,
|
||||||
|
generated_at_value,
|
||||||
|
limit=leaderboard_limit,
|
||||||
|
db_path=db_path,
|
||||||
|
)
|
||||||
|
)
|
||||||
snapshots.append(
|
snapshots.append(
|
||||||
_build_recent_matches_snapshot(
|
_build_recent_matches_snapshot(
|
||||||
server_key,
|
server_key,
|
||||||
@@ -305,6 +332,39 @@ def _build_weekly_leaderboard_snapshot(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_monthly_leaderboard_snapshot(
|
||||||
|
server_key: str,
|
||||||
|
metric: str,
|
||||||
|
generated_at: datetime,
|
||||||
|
*,
|
||||||
|
limit: int,
|
||||||
|
db_path: Path | None = None,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
leaderboard_result = list_monthly_leaderboard(
|
||||||
|
limit=limit,
|
||||||
|
server_id=server_key,
|
||||||
|
metric=metric,
|
||||||
|
db_path=db_path,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"server_key": server_key,
|
||||||
|
"snapshot_type": SNAPSHOT_TYPE_MONTHLY_LEADERBOARD,
|
||||||
|
"metric": metric,
|
||||||
|
"window": DEFAULT_MONTHLY_SNAPSHOT_WINDOW,
|
||||||
|
"generated_at": generated_at,
|
||||||
|
"source_range_start": _parse_optional_timestamp(leaderboard_result.get("window_start")),
|
||||||
|
"source_range_end": _parse_optional_timestamp(leaderboard_result.get("window_end")),
|
||||||
|
"is_stale": False,
|
||||||
|
"payload": {
|
||||||
|
"server_key": server_key,
|
||||||
|
"metric": metric,
|
||||||
|
"limit": limit,
|
||||||
|
"generated_at": _to_iso(generated_at),
|
||||||
|
**leaderboard_result,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _build_recent_matches_snapshot(
|
def _build_recent_matches_snapshot(
|
||||||
server_key: str,
|
server_key: str,
|
||||||
generated_at: datetime,
|
generated_at: datetime,
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ SUPPORTED_WEEKLY_LEADERBOARD_METRICS = frozenset(
|
|||||||
"matches_over_100_kills",
|
"matches_over_100_kills",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
SUPPORTED_MONTHLY_LEADERBOARD_METRICS = SUPPORTED_WEEKLY_LEADERBOARD_METRICS
|
||||||
|
|
||||||
|
|
||||||
def initialize_historical_storage(*, db_path: Path | None = None) -> Path:
|
def initialize_historical_storage(*, db_path: Path | None = None) -> Path:
|
||||||
@@ -1289,6 +1290,165 @@ def list_weekly_top_kills(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_monthly_leaderboard(
|
||||||
|
*,
|
||||||
|
limit: int = 10,
|
||||||
|
server_id: str | None = None,
|
||||||
|
metric: str = "kills",
|
||||||
|
db_path: Path | None = None,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
"""Return ranked monthly leaderboard totals from persisted historical match stats."""
|
||||||
|
resolved_path = initialize_historical_storage(db_path=db_path)
|
||||||
|
aggregate_all_servers = _is_all_servers_selector(server_id)
|
||||||
|
current_time = datetime.now(timezone.utc)
|
||||||
|
current_month_start = _start_of_month(current_time)
|
||||||
|
previous_month_start = _start_of_previous_month(current_month_start)
|
||||||
|
normalized_metric = metric.strip() if isinstance(metric, str) else ""
|
||||||
|
if normalized_metric not in SUPPORTED_MONTHLY_LEADERBOARD_METRICS:
|
||||||
|
raise ValueError(f"Unsupported monthly leaderboard metric: {metric}")
|
||||||
|
|
||||||
|
monthly_window = _select_monthly_window(
|
||||||
|
server_id=server_id,
|
||||||
|
current_time=current_time,
|
||||||
|
current_month_start=current_month_start,
|
||||||
|
previous_month_start=previous_month_start,
|
||||||
|
db_path=resolved_path,
|
||||||
|
)
|
||||||
|
window_start = monthly_window["window_start"]
|
||||||
|
window_end = monthly_window["window_end"]
|
||||||
|
where_clauses = [
|
||||||
|
"historical_matches.ended_at IS NOT NULL",
|
||||||
|
"historical_matches.ended_at >= ?",
|
||||||
|
"historical_matches.ended_at < ?",
|
||||||
|
]
|
||||||
|
params: list[object] = [
|
||||||
|
window_start.isoformat().replace("+00:00", "Z"),
|
||||||
|
window_end.isoformat().replace("+00:00", "Z"),
|
||||||
|
]
|
||||||
|
if server_id and not aggregate_all_servers:
|
||||||
|
normalized_server_id = server_id.strip()
|
||||||
|
where_clauses.append(
|
||||||
|
"(historical_servers.slug = ? OR CAST(historical_servers.server_number AS TEXT) = ?)"
|
||||||
|
)
|
||||||
|
params.extend([normalized_server_id, normalized_server_id])
|
||||||
|
|
||||||
|
server_slug_expression = (
|
||||||
|
f"'{ALL_SERVERS_SLUG}'"
|
||||||
|
if aggregate_all_servers
|
||||||
|
else "historical_servers.slug"
|
||||||
|
)
|
||||||
|
server_name_expression = (
|
||||||
|
f"'{ALL_SERVERS_DISPLAY_NAME}'"
|
||||||
|
if aggregate_all_servers
|
||||||
|
else "historical_servers.display_name"
|
||||||
|
)
|
||||||
|
partition_expression = (
|
||||||
|
f"'{ALL_SERVERS_SLUG}'"
|
||||||
|
if aggregate_all_servers
|
||||||
|
else "historical_servers.slug"
|
||||||
|
)
|
||||||
|
group_by_expression = (
|
||||||
|
"historical_players.id"
|
||||||
|
if aggregate_all_servers
|
||||||
|
else "historical_servers.slug, historical_players.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"""
|
||||||
|
WITH ranked_players AS (
|
||||||
|
SELECT
|
||||||
|
{server_slug_expression} AS server_slug,
|
||||||
|
{server_name_expression} AS server_name,
|
||||||
|
historical_players.stable_player_key,
|
||||||
|
historical_players.display_name AS player_name,
|
||||||
|
historical_players.steam_id,
|
||||||
|
COUNT(DISTINCT historical_matches.id) AS matches_count,
|
||||||
|
{metric_sum_expression} AS metric_value,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY {partition_expression}
|
||||||
|
ORDER BY
|
||||||
|
{metric_sum_expression} DESC,
|
||||||
|
COUNT(DISTINCT historical_matches.id) ASC,
|
||||||
|
historical_players.display_name ASC
|
||||||
|
) AS ranking_position
|
||||||
|
FROM historical_player_match_stats
|
||||||
|
INNER JOIN historical_matches
|
||||||
|
ON historical_matches.id = historical_player_match_stats.historical_match_id
|
||||||
|
INNER JOIN historical_servers
|
||||||
|
ON historical_servers.id = historical_matches.historical_server_id
|
||||||
|
INNER JOIN historical_players
|
||||||
|
ON historical_players.id = historical_player_match_stats.historical_player_id
|
||||||
|
WHERE {" AND ".join(where_clauses)}
|
||||||
|
GROUP BY {group_by_expression}
|
||||||
|
)
|
||||||
|
SELECT *
|
||||||
|
FROM ranked_players
|
||||||
|
WHERE ranking_position <= ?
|
||||||
|
ORDER BY server_slug ASC, ranking_position ASC
|
||||||
|
""",
|
||||||
|
[*params, limit],
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
window_days = _calculate_window_days(window_start=window_start, window_end=window_end)
|
||||||
|
items: list[dict[str, object]] = []
|
||||||
|
for row in rows:
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"server": {
|
||||||
|
"slug": row["server_slug"],
|
||||||
|
"name": row["server_name"],
|
||||||
|
},
|
||||||
|
"time_range": {
|
||||||
|
"start": window_start.isoformat().replace("+00:00", "Z"),
|
||||||
|
"end": window_end.isoformat().replace("+00:00", "Z"),
|
||||||
|
"window_days": window_days,
|
||||||
|
},
|
||||||
|
"player": {
|
||||||
|
"stable_player_key": row["stable_player_key"],
|
||||||
|
"name": row["player_name"],
|
||||||
|
"steam_id": row["steam_id"],
|
||||||
|
},
|
||||||
|
"metric": normalized_metric,
|
||||||
|
"ranking_position": int(row["ranking_position"]),
|
||||||
|
"metric_value": int(row["metric_value"] or 0),
|
||||||
|
"matches_considered": int(row["matches_count"] or 0),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"timeframe": "monthly",
|
||||||
|
"metric": normalized_metric,
|
||||||
|
"window_start": window_start.isoformat().replace("+00:00", "Z"),
|
||||||
|
"window_end": window_end.isoformat().replace("+00:00", "Z"),
|
||||||
|
"window_days": window_days,
|
||||||
|
"window_kind": monthly_window["window_kind"],
|
||||||
|
"window_label": monthly_window["window_label"],
|
||||||
|
"uses_fallback": monthly_window["uses_fallback"],
|
||||||
|
"selection_reason": monthly_window["selection_reason"],
|
||||||
|
"current_month_start": current_month_start.isoformat().replace("+00:00", "Z"),
|
||||||
|
"current_month_closed_matches": monthly_window["current_month_closed_matches"],
|
||||||
|
"previous_month_closed_matches": monthly_window["previous_month_closed_matches"],
|
||||||
|
"sufficient_sample": {
|
||||||
|
"minimum_closed_matches": monthly_window["minimum_closed_matches"],
|
||||||
|
"current_month_closed_matches": monthly_window["current_month_closed_matches"],
|
||||||
|
"current_month_has_sufficient_sample": monthly_window["current_month_has_sufficient_sample"],
|
||||||
|
"is_early_month": monthly_window["is_early_month"],
|
||||||
|
},
|
||||||
|
"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
|
||||||
@@ -2236,6 +2396,59 @@ def _select_weekly_window(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _select_monthly_window(
|
||||||
|
*,
|
||||||
|
server_id: str | None,
|
||||||
|
current_time: datetime,
|
||||||
|
current_month_start: datetime,
|
||||||
|
previous_month_start: datetime,
|
||||||
|
db_path: Path,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
current_month_closed_matches = _count_closed_matches_in_window(
|
||||||
|
server_id=server_id,
|
||||||
|
window_start=current_month_start,
|
||||||
|
window_end=current_time,
|
||||||
|
db_path=db_path,
|
||||||
|
)
|
||||||
|
previous_month_closed_matches = _count_closed_matches_in_window(
|
||||||
|
server_id=server_id,
|
||||||
|
window_start=previous_month_start,
|
||||||
|
window_end=current_month_start,
|
||||||
|
db_path=db_path,
|
||||||
|
)
|
||||||
|
is_early_month = current_time.day <= 3
|
||||||
|
uses_fallback = current_month_closed_matches <= 0 and previous_month_closed_matches > 0
|
||||||
|
|
||||||
|
if uses_fallback:
|
||||||
|
return {
|
||||||
|
"window_start": previous_month_start,
|
||||||
|
"window_end": current_month_start,
|
||||||
|
"window_kind": "previous-closed-month-fallback",
|
||||||
|
"window_label": "Mes cerrado anterior",
|
||||||
|
"uses_fallback": True,
|
||||||
|
"selection_reason": "no-current-month-matches",
|
||||||
|
"minimum_closed_matches": 1,
|
||||||
|
"current_month_closed_matches": current_month_closed_matches,
|
||||||
|
"previous_month_closed_matches": previous_month_closed_matches,
|
||||||
|
"current_month_has_sufficient_sample": False,
|
||||||
|
"is_early_month": is_early_month,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"window_start": current_month_start,
|
||||||
|
"window_end": current_time,
|
||||||
|
"window_kind": "current-month",
|
||||||
|
"window_label": "Mes actual",
|
||||||
|
"uses_fallback": False,
|
||||||
|
"selection_reason": "current-month",
|
||||||
|
"minimum_closed_matches": 1,
|
||||||
|
"current_month_closed_matches": current_month_closed_matches,
|
||||||
|
"previous_month_closed_matches": previous_month_closed_matches,
|
||||||
|
"current_month_has_sufficient_sample": current_month_closed_matches > 0,
|
||||||
|
"is_early_month": is_early_month,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _count_closed_matches_in_window(
|
def _count_closed_matches_in_window(
|
||||||
*,
|
*,
|
||||||
server_id: str | None,
|
server_id: str | None,
|
||||||
@@ -2408,6 +2621,21 @@ def _start_of_week(value: datetime) -> datetime:
|
|||||||
return midnight - timedelta(days=midnight.weekday())
|
return midnight - timedelta(days=midnight.weekday())
|
||||||
|
|
||||||
|
|
||||||
|
def _start_of_month(value: datetime) -> datetime:
|
||||||
|
normalized = value.astimezone(timezone.utc)
|
||||||
|
return normalized.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
|
||||||
|
|
||||||
|
def _start_of_previous_month(value: datetime) -> datetime:
|
||||||
|
previous_day = value - timedelta(days=1)
|
||||||
|
return _start_of_month(previous_day)
|
||||||
|
|
||||||
|
|
||||||
|
def _calculate_window_days(*, window_start: datetime, window_end: datetime) -> int:
|
||||||
|
delta = window_end - window_start
|
||||||
|
return max(1, int((delta.total_seconds() + 86399) // 86400))
|
||||||
|
|
||||||
|
|
||||||
def _get_nested(payload: Mapping[str, object], *path: str) -> object:
|
def _get_nested(payload: Mapping[str, object], *path: str) -> object:
|
||||||
current: object = payload
|
current: object = payload
|
||||||
for key in path:
|
for key in path:
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ from .collector import collect_server_snapshots
|
|||||||
from .config import get_refresh_interval_seconds
|
from .config import get_refresh_interval_seconds
|
||||||
from .historical_snapshot_storage import get_historical_snapshot
|
from .historical_snapshot_storage import get_historical_snapshot
|
||||||
from .historical_snapshots import (
|
from .historical_snapshots import (
|
||||||
|
DEFAULT_MONTHLY_SNAPSHOT_WINDOW,
|
||||||
DEFAULT_SNAPSHOT_WINDOW,
|
DEFAULT_SNAPSHOT_WINDOW,
|
||||||
DEFAULT_WEEKLY_SNAPSHOT_WINDOW,
|
DEFAULT_WEEKLY_SNAPSHOT_WINDOW,
|
||||||
|
SNAPSHOT_TYPE_MONTHLY_LEADERBOARD,
|
||||||
SNAPSHOT_TYPE_RECENT_MATCHES,
|
SNAPSHOT_TYPE_RECENT_MATCHES,
|
||||||
SNAPSHOT_TYPE_SERVER_SUMMARY,
|
SNAPSHOT_TYPE_SERVER_SUMMARY,
|
||||||
SNAPSHOT_TYPE_WEEKLY_LEADERBOARD,
|
SNAPSHOT_TYPE_WEEKLY_LEADERBOARD,
|
||||||
@@ -18,6 +20,7 @@ from .historical_storage import (
|
|||||||
ALL_SERVERS_SLUG,
|
ALL_SERVERS_SLUG,
|
||||||
get_historical_player_profile,
|
get_historical_player_profile,
|
||||||
list_historical_server_summaries,
|
list_historical_server_summaries,
|
||||||
|
list_monthly_leaderboard,
|
||||||
list_recent_historical_matches,
|
list_recent_historical_matches,
|
||||||
list_weekly_leaderboard,
|
list_weekly_leaderboard,
|
||||||
list_weekly_top_kills,
|
list_weekly_top_kills,
|
||||||
@@ -221,36 +224,38 @@ def build_weekly_top_kills_payload(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def build_weekly_leaderboard_payload(
|
def build_historical_leaderboard_payload(
|
||||||
*,
|
*,
|
||||||
limit: int = 10,
|
limit: int = 10,
|
||||||
server_id: str | None = None,
|
server_id: str | None = None,
|
||||||
metric: str = "kills",
|
metric: str = "kills",
|
||||||
|
timeframe: str = "weekly",
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
"""Return one weekly historical leaderboard for the requested metric."""
|
"""Return one historical leaderboard for the requested timeframe and metric."""
|
||||||
|
normalized_timeframe = timeframe.strip().lower() if isinstance(timeframe, str) else "weekly"
|
||||||
|
if normalized_timeframe == "monthly":
|
||||||
|
result = list_monthly_leaderboard(limit=limit, server_id=server_id, metric=metric)
|
||||||
|
summary_basis = "closed-matches-calendar-month"
|
||||||
|
context = "historical-monthly-leaderboard"
|
||||||
|
else:
|
||||||
|
normalized_timeframe = "weekly"
|
||||||
result = list_weekly_leaderboard(limit=limit, server_id=server_id, metric=metric)
|
result = list_weekly_leaderboard(limit=limit, server_id=server_id, metric=metric)
|
||||||
|
summary_basis = "closed-matches-calendar-week"
|
||||||
|
context = "historical-weekly-leaderboard"
|
||||||
|
|
||||||
is_all_servers = server_id == ALL_SERVERS_SLUG
|
is_all_servers = server_id == ALL_SERVERS_SLUG
|
||||||
title_by_metric = {
|
|
||||||
"kills": "Top kills semanales totales" if is_all_servers else "Top kills semanales por servidor",
|
|
||||||
"deaths": "Top muertes semanales totales" if is_all_servers else "Top muertes semanales por servidor",
|
|
||||||
"support": (
|
|
||||||
"Top puntos de soporte semanales totales"
|
|
||||||
if is_all_servers
|
|
||||||
else "Top puntos de soporte semanales por servidor"
|
|
||||||
),
|
|
||||||
"matches_over_100_kills": (
|
|
||||||
"Top partidas de 100+ kills semanales totales"
|
|
||||||
if is_all_servers
|
|
||||||
else "Top partidas de 100+ kills semanales por servidor"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"data": {
|
"data": {
|
||||||
"title": title_by_metric.get(metric, "Ranking semanal por servidor"),
|
"title": _build_leaderboard_title(
|
||||||
"context": "historical-weekly-leaderboard",
|
metric=metric,
|
||||||
|
timeframe=normalized_timeframe,
|
||||||
|
is_all_servers=is_all_servers,
|
||||||
|
),
|
||||||
|
"context": context,
|
||||||
|
"timeframe": normalized_timeframe,
|
||||||
"metric": metric,
|
"metric": metric,
|
||||||
"summary_basis": "closed-matches-calendar-week",
|
"summary_basis": summary_basis,
|
||||||
"window_days": result.get("window_days", 7),
|
"window_days": result.get("window_days", 7),
|
||||||
"window_start": result["window_start"],
|
"window_start": result["window_start"],
|
||||||
"window_end": result["window_end"],
|
"window_end": result["window_end"],
|
||||||
@@ -261,6 +266,9 @@ def build_weekly_leaderboard_payload(
|
|||||||
"current_week_start": result.get("current_week_start"),
|
"current_week_start": result.get("current_week_start"),
|
||||||
"current_week_closed_matches": result.get("current_week_closed_matches"),
|
"current_week_closed_matches": result.get("current_week_closed_matches"),
|
||||||
"previous_week_closed_matches": result.get("previous_week_closed_matches"),
|
"previous_week_closed_matches": result.get("previous_week_closed_matches"),
|
||||||
|
"current_month_start": result.get("current_month_start"),
|
||||||
|
"current_month_closed_matches": result.get("current_month_closed_matches"),
|
||||||
|
"previous_month_closed_matches": result.get("previous_month_closed_matches"),
|
||||||
"sufficient_sample": result.get("sufficient_sample"),
|
"sufficient_sample": result.get("sufficient_sample"),
|
||||||
"limit": limit,
|
"limit": limit,
|
||||||
"items": result["items"],
|
"items": result["items"],
|
||||||
@@ -268,6 +276,21 @@ def build_weekly_leaderboard_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."""
|
||||||
|
return build_historical_leaderboard_payload(
|
||||||
|
limit=limit,
|
||||||
|
server_id=server_id,
|
||||||
|
metric=metric,
|
||||||
|
timeframe="weekly",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def build_recent_historical_matches_payload(
|
def build_recent_historical_matches_payload(
|
||||||
*,
|
*,
|
||||||
limit: int = 20,
|
limit: int = 20,
|
||||||
@@ -314,52 +337,48 @@ def build_historical_server_summary_snapshot_payload(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def build_weekly_leaderboard_snapshot_payload(
|
def build_leaderboard_snapshot_payload(
|
||||||
*,
|
*,
|
||||||
limit: int = 10,
|
limit: int = 10,
|
||||||
server_id: str | None = None,
|
server_id: str | None = None,
|
||||||
metric: str = "kills",
|
metric: str = "kills",
|
||||||
|
timeframe: str = "weekly",
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
"""Return one precomputed weekly leaderboard snapshot."""
|
"""Return one precomputed leaderboard snapshot for the requested timeframe."""
|
||||||
|
normalized_timeframe = timeframe.strip().lower() if isinstance(timeframe, str) else "weekly"
|
||||||
|
if normalized_timeframe == "monthly":
|
||||||
|
snapshot_type = SNAPSHOT_TYPE_MONTHLY_LEADERBOARD
|
||||||
|
window = DEFAULT_MONTHLY_SNAPSHOT_WINDOW
|
||||||
|
context = "historical-monthly-leaderboard-snapshot"
|
||||||
|
else:
|
||||||
|
normalized_timeframe = "weekly"
|
||||||
|
snapshot_type = SNAPSHOT_TYPE_WEEKLY_LEADERBOARD
|
||||||
|
window = DEFAULT_WEEKLY_SNAPSHOT_WINDOW
|
||||||
|
context = "historical-weekly-leaderboard-snapshot"
|
||||||
|
|
||||||
snapshot = _get_historical_snapshot_record(
|
snapshot = _get_historical_snapshot_record(
|
||||||
server_key=server_id,
|
server_key=server_id,
|
||||||
snapshot_type=SNAPSHOT_TYPE_WEEKLY_LEADERBOARD,
|
snapshot_type=snapshot_type,
|
||||||
metric=metric,
|
metric=metric,
|
||||||
window=DEFAULT_WEEKLY_SNAPSHOT_WINDOW,
|
window=window,
|
||||||
)
|
)
|
||||||
payload = snapshot.get("payload") if snapshot else {}
|
payload = snapshot.get("payload") if snapshot else {}
|
||||||
items = payload.get("items") if isinstance(payload, dict) else None
|
items = payload.get("items") if isinstance(payload, dict) else None
|
||||||
sliced_items = list(items[:limit]) if isinstance(items, list) else []
|
sliced_items = list(items[:limit]) if isinstance(items, list) else []
|
||||||
is_all_servers = server_id == ALL_SERVERS_SLUG
|
is_all_servers = server_id == ALL_SERVERS_SLUG
|
||||||
title_by_metric = {
|
|
||||||
"kills": (
|
|
||||||
"Snapshot semanal de top kills totales"
|
|
||||||
if is_all_servers
|
|
||||||
else "Snapshot semanal de top kills por servidor"
|
|
||||||
),
|
|
||||||
"deaths": (
|
|
||||||
"Snapshot semanal de top muertes totales"
|
|
||||||
if is_all_servers
|
|
||||||
else "Snapshot semanal de top muertes por servidor"
|
|
||||||
),
|
|
||||||
"support": (
|
|
||||||
"Snapshot semanal de top soporte total"
|
|
||||||
if is_all_servers
|
|
||||||
else "Snapshot semanal de top soporte por servidor"
|
|
||||||
),
|
|
||||||
"matches_over_100_kills": (
|
|
||||||
"Snapshot semanal de partidas 100+ kills totales"
|
|
||||||
if is_all_servers
|
|
||||||
else "Snapshot semanal de partidas 100+ kills por servidor"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"data": {
|
"data": {
|
||||||
"title": title_by_metric.get(metric, "Snapshot semanal por servidor"),
|
"title": _build_leaderboard_title(
|
||||||
"context": "historical-weekly-leaderboard-snapshot",
|
metric=metric,
|
||||||
|
timeframe=normalized_timeframe,
|
||||||
|
is_all_servers=is_all_servers,
|
||||||
|
snapshot=True,
|
||||||
|
),
|
||||||
|
"context": context,
|
||||||
"source": "historical-precomputed-snapshots",
|
"source": "historical-precomputed-snapshots",
|
||||||
"server_slug": server_id,
|
"server_slug": server_id,
|
||||||
|
"timeframe": normalized_timeframe,
|
||||||
"metric": metric,
|
"metric": metric,
|
||||||
"found": snapshot is not None,
|
"found": snapshot is not None,
|
||||||
**_build_historical_snapshot_metadata(snapshot),
|
**_build_historical_snapshot_metadata(snapshot),
|
||||||
@@ -377,6 +396,13 @@ def build_weekly_leaderboard_snapshot_payload(
|
|||||||
"previous_week_closed_matches": (
|
"previous_week_closed_matches": (
|
||||||
payload.get("previous_week_closed_matches") if isinstance(payload, dict) else None
|
payload.get("previous_week_closed_matches") if isinstance(payload, dict) else None
|
||||||
),
|
),
|
||||||
|
"current_month_start": payload.get("current_month_start") if isinstance(payload, dict) else None,
|
||||||
|
"current_month_closed_matches": (
|
||||||
|
payload.get("current_month_closed_matches") if isinstance(payload, dict) else None
|
||||||
|
),
|
||||||
|
"previous_month_closed_matches": (
|
||||||
|
payload.get("previous_month_closed_matches") if isinstance(payload, dict) else None
|
||||||
|
),
|
||||||
"sufficient_sample": payload.get("sufficient_sample") if isinstance(payload, dict) else None,
|
"sufficient_sample": payload.get("sufficient_sample") if isinstance(payload, dict) else None,
|
||||||
"snapshot_limit": payload.get("limit") if isinstance(payload, dict) else None,
|
"snapshot_limit": payload.get("limit") if isinstance(payload, dict) else None,
|
||||||
"limit": limit,
|
"limit": limit,
|
||||||
@@ -385,6 +411,21 @@ def build_weekly_leaderboard_snapshot_payload(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_weekly_leaderboard_snapshot_payload(
|
||||||
|
*,
|
||||||
|
limit: int = 10,
|
||||||
|
server_id: str | None = None,
|
||||||
|
metric: str = "kills",
|
||||||
|
) -> dict[str, object]:
|
||||||
|
"""Return one precomputed weekly leaderboard snapshot."""
|
||||||
|
return build_leaderboard_snapshot_payload(
|
||||||
|
limit=limit,
|
||||||
|
server_id=server_id,
|
||||||
|
metric=metric,
|
||||||
|
timeframe="weekly",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def build_recent_historical_matches_snapshot_payload(
|
def build_recent_historical_matches_snapshot_payload(
|
||||||
*,
|
*,
|
||||||
limit: int = 20,
|
limit: int = 20,
|
||||||
@@ -499,6 +540,26 @@ def _build_historical_snapshot_metadata(snapshot: dict[str, object] | None) -> d
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_leaderboard_title(
|
||||||
|
*,
|
||||||
|
metric: str,
|
||||||
|
timeframe: str,
|
||||||
|
is_all_servers: bool,
|
||||||
|
snapshot: bool = False,
|
||||||
|
) -> str:
|
||||||
|
timeframe_label = "mensual" if timeframe == "monthly" else "semanal"
|
||||||
|
scope_label = "totales" if is_all_servers else "por servidor"
|
||||||
|
prefix = "Snapshot " if snapshot else ""
|
||||||
|
title_by_metric = {
|
||||||
|
"kills": f"{prefix}Top kills {timeframe_label} {scope_label}",
|
||||||
|
"deaths": f"{prefix}Top muertes {timeframe_label} {scope_label}",
|
||||||
|
"support": f"{prefix}Top puntos de soporte {timeframe_label} {scope_label}",
|
||||||
|
"matches_over_100_kills": f"{prefix}Top partidas de 100+ kills {timeframe_label} {scope_label}",
|
||||||
|
}
|
||||||
|
fallback_label = f"{prefix}Ranking {timeframe_label} por servidor".strip()
|
||||||
|
return title_by_metric.get(metric, fallback_label)
|
||||||
|
|
||||||
|
|
||||||
def _enrich_server_items(items: list[dict[str, object]]) -> list[dict[str, object]]:
|
def _enrich_server_items(items: list[dict[str, object]]) -> list[dict[str, object]]:
|
||||||
target_index = {
|
target_index = {
|
||||||
target.external_server_id: target
|
target.external_server_id: target
|
||||||
|
|||||||
@@ -10,9 +10,11 @@ from .payloads import (
|
|||||||
build_discord_payload,
|
build_discord_payload,
|
||||||
build_error_payload,
|
build_error_payload,
|
||||||
build_health_payload,
|
build_health_payload,
|
||||||
|
build_historical_leaderboard_payload,
|
||||||
build_historical_server_summary_snapshot_payload,
|
build_historical_server_summary_snapshot_payload,
|
||||||
build_historical_player_profile_payload,
|
build_historical_player_profile_payload,
|
||||||
build_historical_server_summary_payload,
|
build_historical_server_summary_payload,
|
||||||
|
build_leaderboard_snapshot_payload,
|
||||||
build_recent_historical_matches_snapshot_payload,
|
build_recent_historical_matches_snapshot_payload,
|
||||||
build_recent_historical_matches_payload,
|
build_recent_historical_matches_payload,
|
||||||
build_server_detail_history_payload,
|
build_server_detail_history_payload,
|
||||||
@@ -54,6 +56,25 @@ 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/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]
|
||||||
|
timeframe = params.get("timeframe", ["weekly"])[0]
|
||||||
|
if metric not in {"kills", "deaths", "support", "matches_over_100_kills"}:
|
||||||
|
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid metric parameter")
|
||||||
|
if timeframe not in {"weekly", "monthly"}:
|
||||||
|
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid timeframe parameter")
|
||||||
|
return HTTPStatus.OK, build_historical_leaderboard_payload(
|
||||||
|
limit=limit,
|
||||||
|
server_id=server_id,
|
||||||
|
metric=metric,
|
||||||
|
timeframe=timeframe,
|
||||||
|
)
|
||||||
|
|
||||||
if parsed.path == "/api/historical/weekly-leaderboard":
|
if parsed.path == "/api/historical/weekly-leaderboard":
|
||||||
limit = _parse_limit(parsed.query)
|
limit = _parse_limit(parsed.query)
|
||||||
if limit is None:
|
if limit is None:
|
||||||
@@ -69,6 +90,25 @@ def resolve_get_payload(path: str) -> tuple[HTTPStatus | None, dict[str, object]
|
|||||||
metric=metric,
|
metric=metric,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if parsed.path == "/api/historical/snapshots/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]
|
||||||
|
timeframe = params.get("timeframe", ["weekly"])[0]
|
||||||
|
if metric not in {"kills", "deaths", "support", "matches_over_100_kills"}:
|
||||||
|
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid metric parameter")
|
||||||
|
if timeframe not in {"weekly", "monthly"}:
|
||||||
|
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid timeframe parameter")
|
||||||
|
return HTTPStatus.OK, build_leaderboard_snapshot_payload(
|
||||||
|
limit=limit,
|
||||||
|
server_id=server_id,
|
||||||
|
metric=metric,
|
||||||
|
timeframe=timeframe,
|
||||||
|
)
|
||||||
|
|
||||||
if parsed.path == "/api/historical/snapshots/weekly-leaderboard":
|
if parsed.path == "/api/historical/snapshots/weekly-leaderboard":
|
||||||
limit = _parse_limit(parsed.query)
|
limit = _parse_limit(parsed.query)
|
||||||
if limit is None:
|
if limit is None:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
## Validation Date
|
## Validation Date
|
||||||
|
|
||||||
- 2026-03-21
|
- 2026-03-21
|
||||||
|
- 2026-03-23
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
@@ -18,6 +19,12 @@ Desde `backend/`:
|
|||||||
python -m app.historical_ingestion bootstrap --max-pages 3 --detail-workers 16
|
python -m app.historical_ingestion bootstrap --max-pages 3 --detail-workers 16
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Bootstrap acotado y reanudable para `comunidad-hispana-03`:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python -m app.historical_ingestion bootstrap --server comunidad-hispana-03 --page-size 10 --max-pages 1 --detail-workers 8
|
||||||
|
```
|
||||||
|
|
||||||
Verificacion puntual previa de idempotencia sobre la primera pagina ya
|
Verificacion puntual previa de idempotencia sobre la primera pagina ya
|
||||||
importada:
|
importada:
|
||||||
|
|
||||||
@@ -60,12 +67,26 @@ larga incluso con paralelismo.
|
|||||||
- ultima partida persistida: `2026-03-20T21:14:21Z`
|
- ultima partida persistida: `2026-03-20T21:14:21Z`
|
||||||
- rango cubierto: `19.18` dias
|
- rango cubierto: `19.18` dias
|
||||||
|
|
||||||
|
### comunidad-hispana-03
|
||||||
|
|
||||||
|
- matches importados: `33`
|
||||||
|
- jugadores unicos: `1161`
|
||||||
|
- filas de estadisticas por jugador: `2547`
|
||||||
|
- primera partida persistida: `2026-02-24T18:16:11Z`
|
||||||
|
- ultima partida persistida: `2026-03-08T18:11:52Z`
|
||||||
|
- rango cubierto: `12.0` dias
|
||||||
|
- total descubierto en la fuente publica: `11652` matches
|
||||||
|
- checkpoint actual de bootstrap: `next_page = 2`, `last_completed_page = 1`
|
||||||
|
|
||||||
## Interpretation
|
## Interpretation
|
||||||
|
|
||||||
- La base persistida ya supera claramente la ventana semanal en ambos
|
- La base persistida ya supera claramente la ventana semanal en ambos
|
||||||
servidores, por lo que la UI historica ya puede distinguir entre "ranking de
|
servidores, por lo que la UI historica ya puede distinguir entre "ranking de
|
||||||
ultimos 7 dias" y "cobertura total importada" sin fingir que ambos conceptos
|
ultimos 7 dias" y "cobertura total importada" sin fingir que ambos conceptos
|
||||||
son lo mismo.
|
son lo mismo.
|
||||||
|
- `comunidad-hispana-03` ya no esta vacio: existe historico real persistido,
|
||||||
|
snapshots de resumen y partidas recientes, y un checkpoint reanudable para
|
||||||
|
seguir ampliando cobertura sin repetir desde cero.
|
||||||
- El historico local sigue siendo parcial respecto al total reportado por la
|
- El historico local sigue siendo parcial respecto al total reportado por la
|
||||||
fuente. Lo importado hoy es suficiente para seguir con semantica y revisiones
|
fuente. Lo importado hoy es suficiente para seguir con semantica y revisiones
|
||||||
de UI, pero no representa aun el archivo completo disponible en CRCON.
|
de UI, pero no representa aun el archivo completo disponible en CRCON.
|
||||||
|
|||||||
@@ -114,6 +114,10 @@
|
|||||||
margin: 0 0 16px;
|
margin: 0 0 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.historical-tabs--timeframe {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.historical-tab {
|
.historical-tab {
|
||||||
min-height: 42px;
|
min-height: 42px;
|
||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
|
|||||||
@@ -25,36 +25,51 @@ const STALE_SNAPSHOT_CACHE_TTL_MS = 30000;
|
|||||||
const NEGATIVE_SNAPSHOT_CACHE_TTL_MS = 15000;
|
const NEGATIVE_SNAPSHOT_CACHE_TTL_MS = 15000;
|
||||||
let activeServerSlug = DEFAULT_HISTORICAL_SERVER;
|
let activeServerSlug = DEFAULT_HISTORICAL_SERVER;
|
||||||
let activeLeaderboardMetric;
|
let activeLeaderboardMetric;
|
||||||
|
let activeLeaderboardTimeframe;
|
||||||
let activeServerRequestId = 0;
|
let activeServerRequestId = 0;
|
||||||
let activeLeaderboardRequestId = 0;
|
let activeLeaderboardRequestId = 0;
|
||||||
|
const LEADERBOARD_TIMEFRAMES = Object.freeze([
|
||||||
|
{
|
||||||
|
key: "weekly",
|
||||||
|
label: "Semanal",
|
||||||
|
shortLabel: "semanal",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "monthly",
|
||||||
|
label: "Mensual",
|
||||||
|
shortLabel: "mensual",
|
||||||
|
},
|
||||||
|
]);
|
||||||
const LEADERBOARD_METRICS = Object.freeze([
|
const LEADERBOARD_METRICS = Object.freeze([
|
||||||
{
|
{
|
||||||
key: "kills",
|
key: "kills",
|
||||||
title: "Top kills semanales",
|
title: "Top kills",
|
||||||
valueHeading: "Kills",
|
valueHeading: "Kills",
|
||||||
emptyMessage: "Sin datos historicos suficientes para mostrar el top de kills semanal.",
|
emptyMessage: "Sin datos historicos suficientes para mostrar este ranking de kills.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "deaths",
|
key: "deaths",
|
||||||
title: "Top muertes semanales",
|
title: "Top muertes",
|
||||||
valueHeading: "Muertes",
|
valueHeading: "Muertes",
|
||||||
emptyMessage: "Sin datos historicos suficientes para mostrar el top de muertes semanal.",
|
emptyMessage: "Sin datos historicos suficientes para mostrar este ranking de muertes.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "matches_over_100_kills",
|
key: "matches_over_100_kills",
|
||||||
title: "Top partidas con 100+ kills",
|
title: "Top partidas con 100+ kills",
|
||||||
valueHeading: "Partidas 100+",
|
valueHeading: "Partidas 100+",
|
||||||
emptyMessage: "Ningun jugador ha registrado partidas de 100+ kills en esta ventana semanal.",
|
emptyMessage: "Ningun jugador ha registrado partidas de 100+ kills en esta ventana.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "support",
|
key: "support",
|
||||||
title: "Top puntos de soporte semanales",
|
title: "Top puntos de soporte",
|
||||||
valueHeading: "Soporte",
|
valueHeading: "Soporte",
|
||||||
emptyMessage: "Sin datos historicos suficientes para mostrar el top de soporte semanal.",
|
emptyMessage: "Sin datos historicos suficientes para mostrar este ranking de soporte.",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
const DEFAULT_LEADERBOARD_METRIC = LEADERBOARD_METRICS[0].key;
|
const DEFAULT_LEADERBOARD_METRIC = LEADERBOARD_METRICS[0].key;
|
||||||
|
const DEFAULT_LEADERBOARD_TIMEFRAME = LEADERBOARD_TIMEFRAMES[0].key;
|
||||||
activeLeaderboardMetric = DEFAULT_LEADERBOARD_METRIC;
|
activeLeaderboardMetric = DEFAULT_LEADERBOARD_METRIC;
|
||||||
|
activeLeaderboardTimeframe = DEFAULT_LEADERBOARD_TIMEFRAME;
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
const backendBaseUrl =
|
const backendBaseUrl =
|
||||||
@@ -62,6 +77,9 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
const selectorButtons = Array.from(
|
const selectorButtons = Array.from(
|
||||||
document.querySelectorAll("[data-server-slug]"),
|
document.querySelectorAll("[data-server-slug]"),
|
||||||
);
|
);
|
||||||
|
const leaderboardTimeframeButtons = Array.from(
|
||||||
|
document.querySelectorAll("[data-leaderboard-timeframe]"),
|
||||||
|
);
|
||||||
const leaderboardTabButtons = Array.from(
|
const leaderboardTabButtons = Array.from(
|
||||||
document.querySelectorAll("[data-leaderboard-metric]"),
|
document.querySelectorAll("[data-leaderboard-metric]"),
|
||||||
);
|
);
|
||||||
@@ -90,6 +108,9 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
activeServerSlug = normalizeServerSlug(params.get("server"));
|
activeServerSlug = normalizeServerSlug(params.get("server"));
|
||||||
activeLeaderboardMetric = normalizeLeaderboardMetric(params.get("metric"));
|
activeLeaderboardMetric = normalizeLeaderboardMetric(params.get("metric"));
|
||||||
|
activeLeaderboardTimeframe = normalizeLeaderboardTimeframe(
|
||||||
|
params.get("timeframe"),
|
||||||
|
);
|
||||||
|
|
||||||
const summaryCache = new Map();
|
const summaryCache = new Map();
|
||||||
const recentMatchesCache = new Map();
|
const recentMatchesCache = new Map();
|
||||||
@@ -112,12 +133,12 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
`${backendBaseUrl}/api/historical/snapshots/recent-matches?server=${encodeURIComponent(serverSlug)}&limit=6`,
|
`${backendBaseUrl}/api/historical/snapshots/recent-matches?server=${encodeURIComponent(serverSlug)}&limit=6`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const getLeaderboardSnapshot = (serverSlug, metricKey) =>
|
const getLeaderboardSnapshot = (serverSlug, timeframeKey, metricKey) =>
|
||||||
getCachedJson(
|
getCachedJson(
|
||||||
leaderboardCache,
|
leaderboardCache,
|
||||||
pendingRequestCache,
|
pendingRequestCache,
|
||||||
buildLeaderboardSnapshotKey(serverSlug, metricKey),
|
buildLeaderboardSnapshotKey(serverSlug, timeframeKey, metricKey),
|
||||||
`${backendBaseUrl}/api/historical/snapshots/weekly-leaderboard?server=${encodeURIComponent(serverSlug)}&metric=${encodeURIComponent(metricKey)}&limit=10`,
|
`${backendBaseUrl}/api/historical/snapshots/leaderboard?server=${encodeURIComponent(serverSlug)}&timeframe=${encodeURIComponent(timeframeKey)}&metric=${encodeURIComponent(metricKey)}&limit=10`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const refreshServerContent = async () => {
|
const refreshServerContent = async () => {
|
||||||
@@ -126,22 +147,32 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
activeServerRequestId = requestId;
|
activeServerRequestId = requestId;
|
||||||
activeLeaderboardRequestId = leaderboardRequestId;
|
activeLeaderboardRequestId = leaderboardRequestId;
|
||||||
const activeMetricConfig = getLeaderboardMetricConfig(activeLeaderboardMetric);
|
const activeMetricConfig = getLeaderboardMetricConfig(activeLeaderboardMetric);
|
||||||
|
const activeTimeframeConfig = getLeaderboardTimeframeConfig(
|
||||||
|
activeLeaderboardTimeframe,
|
||||||
|
);
|
||||||
const activeServerLabel = getHistoricalServerLabel(activeServerSlug);
|
const activeServerLabel = getHistoricalServerLabel(activeServerSlug);
|
||||||
|
|
||||||
syncActiveButtons(selectorButtons, activeServerSlug);
|
syncActiveButtons(selectorButtons, activeServerSlug);
|
||||||
|
syncLeaderboardTimeframes(
|
||||||
|
leaderboardTimeframeButtons,
|
||||||
|
activeLeaderboardTimeframe,
|
||||||
|
);
|
||||||
syncLeaderboardTabs(leaderboardTabButtons, activeLeaderboardMetric);
|
syncLeaderboardTabs(leaderboardTabButtons, activeLeaderboardMetric);
|
||||||
weeklyTitleNode.textContent = buildLeaderboardTitle(
|
weeklyTitleNode.textContent = buildLeaderboardTitle(
|
||||||
activeMetricConfig,
|
activeMetricConfig,
|
||||||
activeServerSlug,
|
activeServerSlug,
|
||||||
|
activeLeaderboardTimeframe,
|
||||||
);
|
);
|
||||||
weeklyValueHeadingNode.textContent = activeMetricConfig.valueHeading;
|
weeklyValueHeadingNode.textContent = activeMetricConfig.valueHeading;
|
||||||
setRangeBadge(rangeNode, "Cargando rango temporal", false);
|
setRangeBadge(rangeNode, "Cargando rango temporal", false);
|
||||||
summaryNoteNode.textContent = `La vista esta leyendo snapshots precalculados del historico local para ${activeServerLabel}.`;
|
summaryNoteNode.textContent = `La vista esta leyendo snapshots precalculados del historico local para ${activeServerLabel}.`;
|
||||||
setSnapshotMeta(summarySnapshotMetaNode, "Cargando snapshot de resumen...");
|
setSnapshotMeta(summarySnapshotMetaNode, "Cargando snapshot de resumen...");
|
||||||
renderSummaryLoading(summaryNode);
|
renderSummaryLoading(summaryNode);
|
||||||
weeklyWindowNoteNode.textContent =
|
weeklyWindowNoteNode.textContent = "Cargando snapshot del ranking activo...";
|
||||||
"Cargando snapshot semanal del alcance activo...";
|
setSnapshotMeta(
|
||||||
setSnapshotMeta(weeklySnapshotMetaNode, "Preparando snapshot semanal...");
|
weeklySnapshotMetaNode,
|
||||||
|
`Preparando snapshot ${activeTimeframeConfig.shortLabel}...`,
|
||||||
|
);
|
||||||
recentListNode.innerHTML = "";
|
recentListNode.innerHTML = "";
|
||||||
recentNoteNode.textContent = buildRecentMatchesNote(activeServerSlug);
|
recentNoteNode.textContent = buildRecentMatchesNote(activeServerSlug);
|
||||||
setState(recentStateNode, "Cargando partidas recientes...");
|
setState(recentStateNode, "Cargando partidas recientes...");
|
||||||
@@ -163,7 +194,11 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
|
|
||||||
const cachedLeaderboardPayload = readCachedPayload(
|
const cachedLeaderboardPayload = readCachedPayload(
|
||||||
leaderboardCache,
|
leaderboardCache,
|
||||||
buildLeaderboardSnapshotKey(activeServerSlug, activeLeaderboardMetric),
|
buildLeaderboardSnapshotKey(
|
||||||
|
activeServerSlug,
|
||||||
|
activeLeaderboardTimeframe,
|
||||||
|
activeLeaderboardMetric,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
if (cachedLeaderboardPayload) {
|
if (cachedLeaderboardPayload) {
|
||||||
hydrateWeeklyLeaderboard(
|
hydrateWeeklyLeaderboard(
|
||||||
@@ -176,9 +211,13 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
weeklyWindowNoteNode,
|
weeklyWindowNoteNode,
|
||||||
weeklySnapshotMetaNode,
|
weeklySnapshotMetaNode,
|
||||||
activeMetricConfig,
|
activeMetricConfig,
|
||||||
|
activeLeaderboardTimeframe,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
setState(weeklyStateNode, "Cargando ranking semanal...");
|
setState(
|
||||||
|
weeklyStateNode,
|
||||||
|
`Cargando ranking ${activeTimeframeConfig.shortLabel}...`,
|
||||||
|
);
|
||||||
weeklyTableNode.hidden = true;
|
weeklyTableNode.hidden = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,9 +235,17 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const targetServerSlug = activeServerSlug;
|
const targetServerSlug = activeServerSlug;
|
||||||
|
const targetTimeframe = activeLeaderboardTimeframe;
|
||||||
const targetMetric = activeLeaderboardMetric;
|
const targetMetric = activeLeaderboardMetric;
|
||||||
void settlePromise(getSummarySnapshot(targetServerSlug)).then((summaryResult) => {
|
void settlePromise(getSummarySnapshot(targetServerSlug)).then((summaryResult) => {
|
||||||
if (!isActiveServerRequest(requestId, targetServerSlug, targetMetric)) {
|
if (
|
||||||
|
!isActiveServerRequest(
|
||||||
|
requestId,
|
||||||
|
targetServerSlug,
|
||||||
|
targetTimeframe,
|
||||||
|
targetMetric,
|
||||||
|
)
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,7 +259,14 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
void settlePromise(getRecentMatchesSnapshot(targetServerSlug)).then((recentMatchesResult) => {
|
void settlePromise(getRecentMatchesSnapshot(targetServerSlug)).then((recentMatchesResult) => {
|
||||||
if (!isActiveServerRequest(requestId, targetServerSlug, targetMetric)) {
|
if (
|
||||||
|
!isActiveServerRequest(
|
||||||
|
requestId,
|
||||||
|
targetServerSlug,
|
||||||
|
targetTimeframe,
|
||||||
|
targetMetric,
|
||||||
|
)
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,13 +279,14 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
void settlePromise(
|
void settlePromise(
|
||||||
getLeaderboardSnapshot(targetServerSlug, targetMetric),
|
getLeaderboardSnapshot(targetServerSlug, targetTimeframe, targetMetric),
|
||||||
).then((leaderboardResult) => {
|
).then((leaderboardResult) => {
|
||||||
if (
|
if (
|
||||||
!isActiveLeaderboardRequest(
|
!isActiveLeaderboardRequest(
|
||||||
requestId,
|
requestId,
|
||||||
leaderboardRequestId,
|
leaderboardRequestId,
|
||||||
targetServerSlug,
|
targetServerSlug,
|
||||||
|
targetTimeframe,
|
||||||
targetMetric,
|
targetMetric,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
@@ -248,6 +303,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
weeklyWindowNoteNode,
|
weeklyWindowNoteNode,
|
||||||
weeklySnapshotMetaNode,
|
weeklySnapshotMetaNode,
|
||||||
activeMetricConfig,
|
activeMetricConfig,
|
||||||
|
targetTimeframe,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -256,19 +312,32 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
const requestId = activeLeaderboardRequestId + 1;
|
const requestId = activeLeaderboardRequestId + 1;
|
||||||
activeLeaderboardRequestId = requestId;
|
activeLeaderboardRequestId = requestId;
|
||||||
const metricConfig = getLeaderboardMetricConfig(activeLeaderboardMetric);
|
const metricConfig = getLeaderboardMetricConfig(activeLeaderboardMetric);
|
||||||
|
const timeframeConfig = getLeaderboardTimeframeConfig(
|
||||||
|
activeLeaderboardTimeframe,
|
||||||
|
);
|
||||||
const targetServerSlug = activeServerSlug;
|
const targetServerSlug = activeServerSlug;
|
||||||
|
const targetTimeframe = activeLeaderboardTimeframe;
|
||||||
const targetMetric = activeLeaderboardMetric;
|
const targetMetric = activeLeaderboardMetric;
|
||||||
|
|
||||||
|
syncLeaderboardTimeframes(
|
||||||
|
leaderboardTimeframeButtons,
|
||||||
|
activeLeaderboardTimeframe,
|
||||||
|
);
|
||||||
syncLeaderboardTabs(leaderboardTabButtons, activeLeaderboardMetric);
|
syncLeaderboardTabs(leaderboardTabButtons, activeLeaderboardMetric);
|
||||||
weeklyTitleNode.textContent = buildLeaderboardTitle(
|
weeklyTitleNode.textContent = buildLeaderboardTitle(
|
||||||
metricConfig,
|
metricConfig,
|
||||||
activeServerSlug,
|
activeServerSlug,
|
||||||
|
activeLeaderboardTimeframe,
|
||||||
);
|
);
|
||||||
weeklyValueHeadingNode.textContent = metricConfig.valueHeading;
|
weeklyValueHeadingNode.textContent = metricConfig.valueHeading;
|
||||||
|
|
||||||
const cachedPayload = readCachedPayload(
|
const cachedPayload = readCachedPayload(
|
||||||
leaderboardCache,
|
leaderboardCache,
|
||||||
buildLeaderboardSnapshotKey(targetServerSlug, targetMetric),
|
buildLeaderboardSnapshotKey(
|
||||||
|
targetServerSlug,
|
||||||
|
targetTimeframe,
|
||||||
|
targetMetric,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
if (cachedPayload) {
|
if (cachedPayload) {
|
||||||
hydrateWeeklyLeaderboard(
|
hydrateWeeklyLeaderboard(
|
||||||
@@ -281,23 +350,30 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
weeklyWindowNoteNode,
|
weeklyWindowNoteNode,
|
||||||
weeklySnapshotMetaNode,
|
weeklySnapshotMetaNode,
|
||||||
metricConfig,
|
metricConfig,
|
||||||
|
targetTimeframe,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
weeklyWindowNoteNode.textContent =
|
weeklyWindowNoteNode.textContent = "Cargando snapshot del ranking activo...";
|
||||||
"Cargando snapshot semanal del alcance activo...";
|
setSnapshotMeta(
|
||||||
setSnapshotMeta(weeklySnapshotMetaNode, "Cargando snapshot semanal...");
|
weeklySnapshotMetaNode,
|
||||||
setState(weeklyStateNode, "Cargando ranking semanal...");
|
`Cargando snapshot ${timeframeConfig.shortLabel}...`,
|
||||||
|
);
|
||||||
|
setState(
|
||||||
|
weeklyStateNode,
|
||||||
|
`Cargando ranking ${timeframeConfig.shortLabel}...`,
|
||||||
|
);
|
||||||
weeklyTableNode.hidden = true;
|
weeklyTableNode.hidden = true;
|
||||||
|
|
||||||
const leaderboardResult = await settlePromise(
|
const leaderboardResult = await settlePromise(
|
||||||
getLeaderboardSnapshot(targetServerSlug, targetMetric),
|
getLeaderboardSnapshot(targetServerSlug, targetTimeframe, targetMetric),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
requestId !== activeLeaderboardRequestId ||
|
requestId !== activeLeaderboardRequestId ||
|
||||||
targetServerSlug !== activeServerSlug ||
|
targetServerSlug !== activeServerSlug ||
|
||||||
|
targetTimeframe !== activeLeaderboardTimeframe ||
|
||||||
targetMetric !== activeLeaderboardMetric
|
targetMetric !== activeLeaderboardMetric
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
@@ -313,6 +389,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
weeklyWindowNoteNode,
|
weeklyWindowNoteNode,
|
||||||
weeklySnapshotMetaNode,
|
weeklySnapshotMetaNode,
|
||||||
metricConfig,
|
metricConfig,
|
||||||
|
targetTimeframe,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -325,12 +402,31 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
|
|
||||||
activeServerSlug = nextServerSlug;
|
activeServerSlug = nextServerSlug;
|
||||||
params.set("server", activeServerSlug);
|
params.set("server", activeServerSlug);
|
||||||
|
params.set("timeframe", activeLeaderboardTimeframe);
|
||||||
params.set("metric", activeLeaderboardMetric);
|
params.set("metric", activeLeaderboardMetric);
|
||||||
window.history.replaceState({}, "", `?${params.toString()}`);
|
window.history.replaceState({}, "", `?${params.toString()}`);
|
||||||
void refreshServerContent();
|
void refreshServerContent();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
leaderboardTimeframeButtons.forEach((button) => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
const nextTimeframe = normalizeLeaderboardTimeframe(
|
||||||
|
button.dataset.leaderboardTimeframe,
|
||||||
|
);
|
||||||
|
if (nextTimeframe === activeLeaderboardTimeframe) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
activeLeaderboardTimeframe = nextTimeframe;
|
||||||
|
params.set("server", activeServerSlug);
|
||||||
|
params.set("timeframe", activeLeaderboardTimeframe);
|
||||||
|
params.set("metric", activeLeaderboardMetric);
|
||||||
|
window.history.replaceState({}, "", `?${params.toString()}`);
|
||||||
|
void refreshLeaderboardContent();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
leaderboardTabButtons.forEach((button) => {
|
leaderboardTabButtons.forEach((button) => {
|
||||||
button.addEventListener("click", () => {
|
button.addEventListener("click", () => {
|
||||||
const nextMetric = normalizeLeaderboardMetric(button.dataset.leaderboardMetric);
|
const nextMetric = normalizeLeaderboardMetric(button.dataset.leaderboardMetric);
|
||||||
@@ -340,6 +436,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
|
|
||||||
activeLeaderboardMetric = nextMetric;
|
activeLeaderboardMetric = nextMetric;
|
||||||
params.set("server", activeServerSlug);
|
params.set("server", activeServerSlug);
|
||||||
|
params.set("timeframe", activeLeaderboardTimeframe);
|
||||||
params.set("metric", activeLeaderboardMetric);
|
params.set("metric", activeLeaderboardMetric);
|
||||||
window.history.replaceState({}, "", `?${params.toString()}`);
|
window.history.replaceState({}, "", `?${params.toString()}`);
|
||||||
void refreshLeaderboardContent();
|
void refreshLeaderboardContent();
|
||||||
@@ -349,10 +446,11 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
void refreshServerContent();
|
void refreshServerContent();
|
||||||
});
|
});
|
||||||
|
|
||||||
function isActiveServerRequest(requestId, serverSlug, metricKey) {
|
function isActiveServerRequest(requestId, serverSlug, timeframeKey, metricKey) {
|
||||||
return (
|
return (
|
||||||
requestId === activeServerRequestId &&
|
requestId === activeServerRequestId &&
|
||||||
serverSlug === activeServerSlug &&
|
serverSlug === activeServerSlug &&
|
||||||
|
timeframeKey === activeLeaderboardTimeframe &&
|
||||||
metricKey === activeLeaderboardMetric
|
metricKey === activeLeaderboardMetric
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -361,10 +459,11 @@ function isActiveLeaderboardRequest(
|
|||||||
serverRequestId,
|
serverRequestId,
|
||||||
leaderboardRequestId,
|
leaderboardRequestId,
|
||||||
serverSlug,
|
serverSlug,
|
||||||
|
timeframeKey,
|
||||||
metricKey,
|
metricKey,
|
||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
isActiveServerRequest(serverRequestId, serverSlug, metricKey) &&
|
isActiveServerRequest(serverRequestId, serverSlug, timeframeKey, metricKey) &&
|
||||||
leaderboardRequestId === activeLeaderboardRequestId
|
leaderboardRequestId === activeLeaderboardRequestId
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -448,35 +547,63 @@ function hydrateWeeklyLeaderboard(
|
|||||||
noteNode,
|
noteNode,
|
||||||
snapshotMetaNode,
|
snapshotMetaNode,
|
||||||
metricConfig,
|
metricConfig,
|
||||||
|
timeframeKey,
|
||||||
) {
|
) {
|
||||||
const targetServerSlug = result.value?.data?.server_slug || activeServerSlug;
|
const targetServerSlug = result.value?.data?.server_slug || activeServerSlug;
|
||||||
|
const resolvedTimeframeKey = result.value?.data?.timeframe || timeframeKey;
|
||||||
valueHeadingNode.textContent = metricConfig.valueHeading;
|
valueHeadingNode.textContent = metricConfig.valueHeading;
|
||||||
if (result.status !== "fulfilled") {
|
if (result.status !== "fulfilled") {
|
||||||
titleNode.textContent = metricConfig.title;
|
titleNode.textContent = buildLeaderboardTitle(
|
||||||
|
metricConfig,
|
||||||
|
targetServerSlug,
|
||||||
|
resolvedTimeframeKey,
|
||||||
|
);
|
||||||
noteNode.textContent =
|
noteNode.textContent =
|
||||||
"No se pudo leer el snapshot semanal precalculado para esta metrica.";
|
"No se pudo leer el snapshot precalculado para esta metrica.";
|
||||||
setSnapshotMeta(snapshotMetaNode, "Error al leer el snapshot semanal.");
|
setSnapshotMeta(snapshotMetaNode, "Error al leer el snapshot del ranking.");
|
||||||
setState(stateNode, "No se pudo cargar el ranking semanal.", true);
|
setState(
|
||||||
|
stateNode,
|
||||||
|
`No se pudo cargar el ranking ${getLeaderboardTimeframeConfig(resolvedTimeframeKey).shortLabel}.`,
|
||||||
|
true,
|
||||||
|
);
|
||||||
tableNode.hidden = true;
|
tableNode.hidden = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = result.value?.data;
|
const payload = result.value?.data;
|
||||||
titleNode.textContent = buildLeaderboardTitle(metricConfig, payload?.server_slug);
|
titleNode.textContent = buildLeaderboardTitle(
|
||||||
|
metricConfig,
|
||||||
|
payload?.server_slug,
|
||||||
|
payload?.timeframe || resolvedTimeframeKey,
|
||||||
|
);
|
||||||
noteNode.textContent = buildWeeklyWindowNote(payload);
|
noteNode.textContent = buildWeeklyWindowNote(payload);
|
||||||
setSnapshotMeta(
|
setSnapshotMeta(
|
||||||
snapshotMetaNode,
|
snapshotMetaNode,
|
||||||
buildSnapshotMetaText(payload, "Snapshot semanal pendiente de generacion."),
|
buildSnapshotMetaText(payload, "Snapshot del ranking pendiente de generacion."),
|
||||||
);
|
);
|
||||||
if (!payload?.found) {
|
if (!payload?.found) {
|
||||||
setState(stateNode, buildLeaderboardEmptyMessage(metricConfig, targetServerSlug));
|
setState(
|
||||||
|
stateNode,
|
||||||
|
buildLeaderboardEmptyMessage(
|
||||||
|
metricConfig,
|
||||||
|
targetServerSlug,
|
||||||
|
payload?.timeframe || resolvedTimeframeKey,
|
||||||
|
),
|
||||||
|
);
|
||||||
tableNode.hidden = true;
|
tableNode.hidden = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const items = payload?.items;
|
const items = payload?.items;
|
||||||
if (!Array.isArray(items) || items.length === 0) {
|
if (!Array.isArray(items) || items.length === 0) {
|
||||||
setState(stateNode, buildLeaderboardEmptyMessage(metricConfig, targetServerSlug));
|
setState(
|
||||||
|
stateNode,
|
||||||
|
buildLeaderboardEmptyMessage(
|
||||||
|
metricConfig,
|
||||||
|
targetServerSlug,
|
||||||
|
payload?.timeframe || resolvedTimeframeKey,
|
||||||
|
),
|
||||||
|
);
|
||||||
tableNode.hidden = true;
|
tableNode.hidden = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -612,6 +739,14 @@ function syncLeaderboardTabs(buttons, activeMetric) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncLeaderboardTimeframes(buttons, activeTimeframe) {
|
||||||
|
buttons.forEach((button) => {
|
||||||
|
const isActive = button.dataset.leaderboardTimeframe === activeTimeframe;
|
||||||
|
button.classList.toggle("is-active", isActive);
|
||||||
|
button.setAttribute("aria-selected", String(isActive));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeServerSlug(rawValue) {
|
function normalizeServerSlug(rawValue) {
|
||||||
const normalized = typeof rawValue === "string" ? rawValue.trim() : "";
|
const normalized = typeof rawValue === "string" ? rawValue.trim() : "";
|
||||||
if (HISTORICAL_SERVER_SLUGS.includes(normalized)) {
|
if (HISTORICAL_SERVER_SLUGS.includes(normalized)) {
|
||||||
@@ -637,6 +772,15 @@ function normalizeLeaderboardMetric(rawValue) {
|
|||||||
return DEFAULT_LEADERBOARD_METRIC;
|
return DEFAULT_LEADERBOARD_METRIC;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeLeaderboardTimeframe(rawValue) {
|
||||||
|
const normalized = typeof rawValue === "string" ? rawValue.trim() : "";
|
||||||
|
if (LEADERBOARD_TIMEFRAMES.some((timeframe) => timeframe.key === normalized)) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
return DEFAULT_LEADERBOARD_TIMEFRAME;
|
||||||
|
}
|
||||||
|
|
||||||
function getLeaderboardMetricConfig(metricKey) {
|
function getLeaderboardMetricConfig(metricKey) {
|
||||||
return (
|
return (
|
||||||
LEADERBOARD_METRICS.find((metric) => metric.key === metricKey) ||
|
LEADERBOARD_METRICS.find((metric) => metric.key === metricKey) ||
|
||||||
@@ -644,6 +788,13 @@ function getLeaderboardMetricConfig(metricKey) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getLeaderboardTimeframeConfig(timeframeKey) {
|
||||||
|
return (
|
||||||
|
LEADERBOARD_TIMEFRAMES.find((timeframe) => timeframe.key === timeframeKey) ||
|
||||||
|
LEADERBOARD_TIMEFRAMES[0]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function buildSummarySnapshotKey(serverSlug) {
|
function buildSummarySnapshotKey(serverSlug) {
|
||||||
return `summary:${serverSlug}`;
|
return `summary:${serverSlug}`;
|
||||||
}
|
}
|
||||||
@@ -652,8 +803,8 @@ function buildRecentMatchesSnapshotKey(serverSlug) {
|
|||||||
return `recent:${serverSlug}`;
|
return `recent:${serverSlug}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildLeaderboardSnapshotKey(serverSlug, metricKey) {
|
function buildLeaderboardSnapshotKey(serverSlug, timeframeKey, metricKey) {
|
||||||
return `leaderboard:${serverSlug}:${metricKey}`;
|
return `leaderboard:${serverSlug}:${timeframeKey}:${metricKey}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildRangeLabel(start, end) {
|
function buildRangeLabel(start, end) {
|
||||||
@@ -668,9 +819,7 @@ function buildCoverageBadgeLabel(coverage, timeRange, serverSlug) {
|
|||||||
const rangeStart = coverage?.first_match_at || timeRange?.start;
|
const rangeStart = coverage?.first_match_at || timeRange?.start;
|
||||||
const rangeEnd = coverage?.last_match_at || timeRange?.end;
|
const rangeEnd = coverage?.last_match_at || timeRange?.end;
|
||||||
if (!rangeStart && !rangeEnd) {
|
if (!rangeStart && !rangeEnd) {
|
||||||
return serverSlug === "comunidad-hispana-03"
|
return "Sin cobertura registrada";
|
||||||
? "Pendiente de historico"
|
|
||||||
: "Sin cobertura registrada";
|
|
||||||
}
|
}
|
||||||
if (coverage?.status === "under-week") {
|
if (coverage?.status === "under-week") {
|
||||||
return "Cobertura inicial";
|
return "Cobertura inicial";
|
||||||
@@ -693,9 +842,6 @@ function buildCoveragePeriodLabel(coverage, timeRange, serverSlug) {
|
|||||||
if (end) {
|
if (end) {
|
||||||
return `Hasta ${formatDateOnly(end)}`;
|
return `Hasta ${formatDateOnly(end)}`;
|
||||||
}
|
}
|
||||||
if (serverSlug === "comunidad-hispana-03") {
|
|
||||||
return "Pendiente de bootstrap historico";
|
|
||||||
}
|
|
||||||
return "Sin cobertura registrada";
|
return "Sin cobertura registrada";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -706,9 +852,7 @@ function buildSummaryNote(summaryBasis, weeklyWindowDays, coverage, serverSlug)
|
|||||||
: "el historico persistido disponible";
|
: "el historico persistido disponible";
|
||||||
const status = coverage?.status;
|
const status = coverage?.status;
|
||||||
void weeklyWindowDays;
|
void weeklyWindowDays;
|
||||||
if (serverSlug === "comunidad-hispana-03" && status === "empty") {
|
void serverSlug;
|
||||||
return "Comunidad Hispana #03 todavia no tiene historico registrado. Este bloque se activara cuando se ejecute su primer bootstrap.";
|
|
||||||
}
|
|
||||||
if (status === "under-week") {
|
if (status === "under-week") {
|
||||||
return `Este bloque resume ${basisLabel}. La cobertura registrada todavia es inicial y puede crecer en los proximos dias.`;
|
return `Este bloque resume ${basisLabel}. La cobertura registrada todavia es inicial y puede crecer en los proximos dias.`;
|
||||||
}
|
}
|
||||||
@@ -720,36 +864,38 @@ function buildSummaryNote(summaryBasis, weeklyWindowDays, coverage, serverSlug)
|
|||||||
|
|
||||||
function buildWeeklyWindowNote(payload) {
|
function buildWeeklyWindowNote(payload) {
|
||||||
if (!payload?.found) {
|
if (!payload?.found) {
|
||||||
if ((payload?.server_slug || activeServerSlug) === "comunidad-hispana-03") {
|
const timeframeLabel = getLeaderboardTimeframeConfig(
|
||||||
return "El ranking semanal aparecera cuando Comunidad Hispana #03 tenga su primer bootstrap historico.";
|
payload?.timeframe || activeLeaderboardTimeframe,
|
||||||
}
|
).shortLabel;
|
||||||
return "No existe un snapshot semanal listo para esta metrica en el alcance activo.";
|
return `No existe un snapshot ${timeframeLabel} listo para esta metrica en el alcance activo.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const start = formatTimestamp(payload?.window_start);
|
const start = formatTimestamp(payload?.window_start);
|
||||||
const end = formatTimestamp(payload?.window_end);
|
const end = formatTimestamp(payload?.window_end);
|
||||||
const windowLabel = payload?.window_label || "Semana activa";
|
const windowLabel =
|
||||||
|
payload?.window_label ||
|
||||||
|
(payload?.timeframe === "monthly" ? "Mes activo" : "Semana activa");
|
||||||
if (payload?.uses_fallback) {
|
if (payload?.uses_fallback) {
|
||||||
const currentWeekMatches =
|
const currentPeriodMatches =
|
||||||
payload?.current_week_closed_matches ??
|
payload?.current_week_closed_matches ??
|
||||||
|
payload?.current_month_closed_matches ??
|
||||||
payload?.sufficient_sample?.current_week_closed_matches ??
|
payload?.sufficient_sample?.current_week_closed_matches ??
|
||||||
|
payload?.sufficient_sample?.current_month_closed_matches ??
|
||||||
0;
|
0;
|
||||||
return `${windowLabel}: ${start} a ${end}. Se muestra la ultima semana cerrada porque la actual todavia solo suma ${formatNumber(currentWeekMatches)} cierres.`;
|
return `${windowLabel}: ${start} a ${end}. Se muestra el ultimo periodo cerrado porque el actual todavia solo suma ${formatNumber(currentPeriodMatches)} cierres.`;
|
||||||
}
|
}
|
||||||
return `${windowLabel}: ${start} a ${end}.`;
|
return `${windowLabel}: ${start} a ${end}.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildLeaderboardTitle(metricConfig, serverSlug) {
|
function buildLeaderboardTitle(metricConfig, serverSlug, timeframeKey) {
|
||||||
return `${metricConfig.title} - ${getHistoricalServerLabel(serverSlug)}`;
|
const timeframeLabel = getLeaderboardTimeframeConfig(timeframeKey).label;
|
||||||
|
return `${metricConfig.title} ${timeframeLabel} - ${getHistoricalServerLabel(serverSlug)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildRecentMatchesNote(serverSlug) {
|
function buildRecentMatchesNote(serverSlug) {
|
||||||
if (serverSlug === "all-servers") {
|
if (serverSlug === "all-servers") {
|
||||||
return "Lista de cierres ya registrados para los servidores con historico disponible.";
|
return "Lista de cierres ya registrados para los servidores con historico disponible.";
|
||||||
}
|
}
|
||||||
if (serverSlug === "comunidad-hispana-03") {
|
|
||||||
return "Este bloque quedara activo cuando Comunidad Hispana #03 tenga su primer registro historico.";
|
|
||||||
}
|
|
||||||
return `Lista de cierres ya registrados para ${getHistoricalServerLabel(serverSlug)}.`;
|
return `Lista de cierres ya registrados para ${getHistoricalServerLabel(serverSlug)}.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -935,24 +1081,14 @@ function escapeHtml(value) {
|
|||||||
.replaceAll("'", "'");
|
.replaceAll("'", "'");
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildLeaderboardEmptyMessage(metricConfig, serverSlug) {
|
function buildLeaderboardEmptyMessage(metricConfig, serverSlug, timeframeKey) {
|
||||||
if (serverSlug === "comunidad-hispana-03") {
|
void serverSlug;
|
||||||
return "Comunidad Hispana #03 todavia no tiene historico registrado para mostrar este ranking.";
|
const timeframeLabel = getLeaderboardTimeframeConfig(timeframeKey).shortLabel;
|
||||||
}
|
return metricConfig.emptyMessage.replace("esta ventana", `esta ventana ${timeframeLabel}`);
|
||||||
return metricConfig.emptyMessage;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getHistoricalEmptyState(serverSlug) {
|
function getHistoricalEmptyState(serverSlug) {
|
||||||
if (serverSlug === "comunidad-hispana-03") {
|
void serverSlug;
|
||||||
return {
|
|
||||||
rangeLabel: "Pendiente de historico",
|
|
||||||
summaryMessage: "Sin historico registrado todavia",
|
|
||||||
summaryNote:
|
|
||||||
"Comunidad Hispana #03 sigue pendiente de bootstrap historico. Cuando se registren sus primeras partidas, aqui apareceran su cobertura y sus totales.",
|
|
||||||
recentMessage:
|
|
||||||
"Comunidad Hispana #03 todavia no tiene partidas historicas registradas.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rangeLabel: "Sin cobertura registrada",
|
rangeLabel: "Sin cobertura registrada",
|
||||||
|
|||||||
@@ -111,21 +111,45 @@
|
|||||||
<div class="panel__shell">
|
<div class="panel__shell">
|
||||||
<div class="panel__header historical-panel__header">
|
<div class="panel__header historical-panel__header">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow eyebrow--section">Ranking semanal</p>
|
<p class="eyebrow eyebrow--section">Rankings historicos</p>
|
||||||
<h2 id="weekly-ranking-title">Ranking semanal del alcance activo</h2>
|
<h2 id="weekly-ranking-title">Ranking del alcance activo</h2>
|
||||||
<p class="historical-panel__note" id="weekly-window-note">
|
<p class="historical-panel__note" id="weekly-window-note">
|
||||||
Cargando ventana semanal...
|
Cargando rango del ranking...
|
||||||
</p>
|
</p>
|
||||||
<p class="historical-snapshot-meta" id="weekly-leaderboard-snapshot-meta">
|
<p class="historical-snapshot-meta" id="weekly-leaderboard-snapshot-meta">
|
||||||
Cargando datos semanal...
|
Cargando snapshot del ranking...
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
class="historical-tabs historical-tabs--timeframe"
|
||||||
|
role="tablist"
|
||||||
|
aria-label="Marco temporal del ranking"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="historical-tab is-active"
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected="true"
|
||||||
|
data-leaderboard-timeframe="weekly"
|
||||||
|
>
|
||||||
|
Semanal
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="historical-tab"
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected="false"
|
||||||
|
data-leaderboard-timeframe="monthly"
|
||||||
|
>
|
||||||
|
Mensual
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
class="historical-tabs"
|
class="historical-tabs"
|
||||||
id="weekly-leaderboard-tabs"
|
id="weekly-leaderboard-tabs"
|
||||||
role="tablist"
|
role="tablist"
|
||||||
aria-label="Metricas del ranking semanal"
|
aria-label="Metricas del ranking"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
class="historical-tab is-active"
|
class="historical-tab is-active"
|
||||||
|
|||||||
Reference in New Issue
Block a user