Process historical bootstrap and leaderboard tasks

This commit is contained in:
devRaGonSa
2026-03-23 15:24:49 +01:00
parent f35ce58a84
commit d18cb6d05b
13 changed files with 936 additions and 163 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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:

View File

@@ -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"

View File

@@ -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,

View File

@@ -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:

View File

@@ -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."""
result = list_weekly_leaderboard(limit=limit, server_id=server_id, metric=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)
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

View File

@@ -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:

View File

@@ -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.

View File

@@ -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;

View File

@@ -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("'", "&#39;"); .replaceAll("'", "&#39;");
} }
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",

View File

@@ -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"