Complete historical snapshot generation and weekly fallback
This commit is contained in:
@@ -0,0 +1,104 @@
|
|||||||
|
# TASK-046-historical-snapshots-generation-and-weekly-fallback
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Completar la capa operativa de snapshots históricos para que la página histórica deje de mostrar estados vacíos cuando ya existe histórico bruto persistido, y añadir una regla de fallback semanal que use la semana cerrada anterior cuando la semana actual todavía no tenga muestra suficiente, especialmente a principio de semana.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
La página histórica ya consume endpoints de snapshots precalculados, pero en el estado actual del proyecto esos snapshots no están generados o no se están persistiendo de forma efectiva para el servidor activo. Como resultado, la UI muestra mensajes como “Sin snapshot de resumen” o “Sin datos históricos suficientes” aunque el bootstrap histórico ya haya cargado datos crudos en SQLite.
|
||||||
|
|
||||||
|
Además, la experiencia esperada para los rankings semanales requiere una regla adicional: si la semana actual aún no tiene muestra suficiente —por ejemplo, en lunes o muy al inicio de la semana— debe mostrarse temporalmente la semana cerrada anterior hasta que la actual tenga datos suficientes.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
1. Revisar la implementación actual de:
|
||||||
|
- histórico bruto persistido
|
||||||
|
- capa de snapshots
|
||||||
|
- API de snapshots
|
||||||
|
- UI histórica que consume snapshots
|
||||||
|
2. Identificar por qué, tras el bootstrap histórico, no se están generando o persistiendo snapshots disponibles para la UI.
|
||||||
|
3. Completar o corregir el flujo de generación de snapshots para:
|
||||||
|
- resumen de servidor
|
||||||
|
- weekly leaderboard
|
||||||
|
- recent matches
|
||||||
|
4. Asegurar que exista una forma clara de generar snapshots después del bootstrap y de refrescarlos periódicamente.
|
||||||
|
5. Validar que, una vez existe histórico bruto suficiente, la UI deje de mostrar mensajes de “sin snapshot” para el servidor activo.
|
||||||
|
6. Implementar en backend una regla de selección de leaderboard semanal con fallback:
|
||||||
|
- si la semana actual no tiene muestra suficiente
|
||||||
|
- y estamos en los primeros días de la semana o en una condición equivalente definida por el proyecto
|
||||||
|
- devolver la semana cerrada anterior como snapshot activo
|
||||||
|
7. Definir y documentar claramente qué significa “muestra suficiente” para este fallback semanal.
|
||||||
|
8. Hacer que el payload del snapshot semanal exponga de forma clara:
|
||||||
|
- el rango temporal real usado
|
||||||
|
- si el snapshot pertenece a la semana actual o a la semana cerrada anterior
|
||||||
|
- cualquier metadato útil para que la UI no engañe al usuario
|
||||||
|
9. Ajustar la UI solo en lo mínimo necesario para reflejar correctamente el rango real que se está mostrando.
|
||||||
|
10. Mantener intacta la separación entre:
|
||||||
|
- histórico bruto
|
||||||
|
- snapshots precalculados
|
||||||
|
- live status A2S
|
||||||
|
11. 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_storage.py
|
||||||
|
- backend/app/historical_models.py
|
||||||
|
- backend/app/historical_runner.py
|
||||||
|
- backend/app/historical_snapshots.py
|
||||||
|
- backend/app/historical_snapshot_storage.py
|
||||||
|
- backend/app/payloads.py
|
||||||
|
- backend/app/routes.py
|
||||||
|
- frontend/historico.html
|
||||||
|
- frontend/assets/js/historico.js
|
||||||
|
- frontend/assets/css/historico.css
|
||||||
|
|
||||||
|
## Expected Files to Modify
|
||||||
|
- backend/README.md
|
||||||
|
- backend/app/config.py
|
||||||
|
- backend/app/historical_runner.py
|
||||||
|
- backend/app/historical_snapshots.py
|
||||||
|
- backend/app/historical_snapshot_storage.py
|
||||||
|
- backend/app/payloads.py
|
||||||
|
- backend/app/routes.py
|
||||||
|
- opcionalmente frontend/assets/js/historico.js si hace falta reflejar el rango semanal real usado
|
||||||
|
- opcionalmente documentación técnica adicional
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
- No usar A2S para esta lógica histórica.
|
||||||
|
- No volver a queries pesadas on-demand como solución principal.
|
||||||
|
- No crear páginas nuevas.
|
||||||
|
- No romper la UI histórica existente.
|
||||||
|
- No hacer cambios destructivos.
|
||||||
|
- Mantener el trabajo centrado en generación real de snapshots y fallback semanal coherente.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
- La página histórica deja de mostrar “sin snapshot” cuando ya existe histórico bruto suficiente.
|
||||||
|
- Se generan snapshots de resumen, leaderboard semanal y partidas recientes.
|
||||||
|
- El leaderboard semanal puede usar la semana anterior cuando la actual aún no tiene muestra suficiente.
|
||||||
|
- El payload expone claramente el rango temporal real mostrado.
|
||||||
|
- La UI deja claro qué semana está viendo el usuario.
|
||||||
|
- Los cambios quedan committeados y se hace push al remoto si el entorno lo permite.
|
||||||
|
|
||||||
|
## Change Budget
|
||||||
|
- Preferir menos de 7 archivos modificados o creados.
|
||||||
|
- Preferir menos de 260 líneas cambiadas.
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
- La generacion de snapshots historicos deja de depender solo del runner periodico: `bootstrap` y `refresh` regeneran y persisten snapshots al terminar correctamente.
|
||||||
|
- Se consolidaron snapshots reales para `server-summary`, `weekly-leaderboard` y `recent-matches`.
|
||||||
|
- El ranking semanal paso de una ventana movil de 7 dias a una semana calendario UTC con fallback explicito a la semana cerrada anterior.
|
||||||
|
- Se definio "muestra suficiente" como minimo `3` partidas cerradas en la semana actual; el fallback puede aplicarse entre lunes y miercoles UTC si esa muestra aun no existe.
|
||||||
|
- El payload semanal ahora expone `window_start`, `window_end`, `window_kind`, `window_label`, `uses_fallback`, `selection_reason`, `current_week_closed_matches`, `previous_week_closed_matches` y `sufficient_sample`.
|
||||||
|
- La UI historica ajusta el texto de la ventana semanal para reflejar el rango real mostrado y avisar cuando se esta viendo temporalmente la semana cerrada anterior.
|
||||||
|
- Se supero ligeramente el presupuesto orientativo de lineas por mantener la logica de seleccion semanal y la documentacion operativa en un mismo cambio acotado.
|
||||||
|
|
||||||
|
## Validation Notes
|
||||||
|
- `python -m py_compile backend/app/config.py backend/app/historical_storage.py backend/app/historical_snapshots.py backend/app/historical_runner.py backend/app/historical_ingestion.py backend/app/payloads.py`
|
||||||
|
- `generate_and_persist_historical_snapshots(server_key='comunidad-hispana-01')` persistio `6` snapshots para el servidor activo.
|
||||||
|
- Los builders de payload devolvieron `found: True` para `server-summary`, `weekly-leaderboard` y `recent-matches` en `comunidad-hispana-01`.
|
||||||
|
- Con la base SQLite local actual, el snapshot semanal activo devuelve `window_kind: previous-closed-week-fallback`, `uses_fallback: True`, `current_week_closed_matches: 0` y `previous_week_closed_matches: 44`, lo que valida el fallback real a fecha `2026-03-23`.
|
||||||
|
- Se valido el nuevo encadenado de ingesta -> regeneracion de snapshots con una ejecucion local stubbeada de `run_incremental_refresh(...)` sin depender de red; el resultado incluyo `snapshot_result.snapshot_count = 6`.
|
||||||
|
- `git diff --name-only` confirma que solo cambiaron archivos del backend historico, la documentacion backend y el JS minimo de la pagina historica.
|
||||||
@@ -73,6 +73,8 @@ Variables opcionales:
|
|||||||
- `HLL_HISTORICAL_SNAPSHOT_REFRESH_INTERVAL_SECONDS`
|
- `HLL_HISTORICAL_SNAPSHOT_REFRESH_INTERVAL_SECONDS`
|
||||||
- `HLL_HISTORICAL_REFRESH_MAX_RETRIES`
|
- `HLL_HISTORICAL_REFRESH_MAX_RETRIES`
|
||||||
- `HLL_HISTORICAL_REFRESH_RETRY_DELAY_SECONDS`
|
- `HLL_HISTORICAL_REFRESH_RETRY_DELAY_SECONDS`
|
||||||
|
- `HLL_HISTORICAL_WEEKLY_FALLBACK_MIN_MATCHES`
|
||||||
|
- `HLL_HISTORICAL_WEEKLY_FALLBACK_MAX_WEEKDAY`
|
||||||
|
|
||||||
El `frontend/index.html` viene preparado para volver a consultar el bloque de
|
El `frontend/index.html` viene preparado para volver a consultar el bloque de
|
||||||
servidores cada `120000` ms (`120s`) sin recargar la pagina completa. La landing
|
servidores cada `120000` ms (`120s`) sin recargar la pagina completa. La landing
|
||||||
@@ -476,15 +478,16 @@ Parametros opcionales:
|
|||||||
- `player` en `/api/historical/player-profile` aceptando `stable_player_key`,
|
- `player` en `/api/historical/player-profile` aceptando `stable_player_key`,
|
||||||
`steam_id` o `source_player_id`
|
`steam_id` o `source_player_id`
|
||||||
|
|
||||||
La ventana temporal es siempre la ultima semana movil respecto al momento de la
|
La ventana temporal usa semana calendario UTC y solo considera partidas
|
||||||
request y solo considera partidas cerradas con `ended_at` para no mezclar
|
cerradas con `ended_at` para no mezclar partidas aun en curso ni filas
|
||||||
partidas aun en curso ni filas historicas transitorias. El payload devuelve
|
historicas transitorias. El payload devuelve servidor, rango temporal,
|
||||||
servidor, rango temporal, jugador, kills semanales, posicion y numero de
|
jugador, kills semanales, posicion y numero de partidas consideradas.
|
||||||
partidas consideradas.
|
|
||||||
|
|
||||||
`weekly-leaderboard` generaliza ese bloque para varias metricas semanales por
|
`weekly-leaderboard` generaliza ese bloque para varias metricas semanales por
|
||||||
servidor usando la misma ventana movil de 7 dias y el mismo filtro de partidas
|
servidor usando el mismo filtro de partidas cerradas. Si la semana actual cae
|
||||||
cerradas. Metricas soportadas:
|
entre lunes y miercoles UTC y todavia no acumula al menos `3` partidas
|
||||||
|
cerradas, el backend activa un fallback temporal a la semana cerrada anterior.
|
||||||
|
Metricas soportadas:
|
||||||
|
|
||||||
- `kills`
|
- `kills`
|
||||||
- `deaths`
|
- `deaths`
|
||||||
@@ -509,6 +512,15 @@ request. Estos endpoints devuelven payloads ligeros listos para frontend con:
|
|||||||
- `is_stale`
|
- `is_stale`
|
||||||
- `freshness`
|
- `freshness`
|
||||||
- `found`
|
- `found`
|
||||||
|
- `window_start`
|
||||||
|
- `window_end`
|
||||||
|
- `window_kind`
|
||||||
|
- `window_label`
|
||||||
|
- `uses_fallback`
|
||||||
|
- `selection_reason`
|
||||||
|
- `current_week_closed_matches`
|
||||||
|
- `previous_week_closed_matches`
|
||||||
|
- `sufficient_sample`
|
||||||
|
|
||||||
`/api/historical/snapshots/server-summary` devuelve `item` con el resumen del
|
`/api/historical/snapshots/server-summary` devuelve `item` con el resumen del
|
||||||
servidor. `/api/historical/snapshots/weekly-leaderboard` devuelve `items` ya
|
servidor. `/api/historical/snapshots/weekly-leaderboard` devuelve `items` ya
|
||||||
@@ -547,7 +559,9 @@ Flags utiles:
|
|||||||
La ejecucion `bootstrap` recorre paginas historicas hasta agotar resultados.
|
La ejecucion `bootstrap` recorre paginas historicas hasta agotar resultados.
|
||||||
La ejecucion `refresh` usa una ventana de solape sobre la ultima partida
|
La ejecucion `refresh` usa una ventana de solape sobre la ultima partida
|
||||||
persistida por servidor para releer solo paginas recientes y absorber updates
|
persistida por servidor para releer solo paginas recientes y absorber updates
|
||||||
tardios sin reimportar todo el historico.
|
tardios sin reimportar todo el historico. Cuando una ejecucion termina
|
||||||
|
correctamente, tambien recompone los snapshots historicos precalculados para el
|
||||||
|
servidor afectado o para todos los servidores si la ingesta fue global.
|
||||||
|
|
||||||
El comando devuelve ademas un resumen de cobertura persistida por servidor. Esto
|
El comando devuelve ademas un resumen de cobertura persistida por servidor. Esto
|
||||||
ayuda a validar rapidamente cuantos matches reales quedaron importados, el rango
|
ayuda a validar rapidamente cuantos matches reales quedaron importados, el rango
|
||||||
@@ -610,6 +624,8 @@ Variables utiles del runner:
|
|||||||
- `HLL_HISTORICAL_SNAPSHOT_REFRESH_INTERVAL_SECONDS`
|
- `HLL_HISTORICAL_SNAPSHOT_REFRESH_INTERVAL_SECONDS`
|
||||||
- `HLL_HISTORICAL_REFRESH_MAX_RETRIES`
|
- `HLL_HISTORICAL_REFRESH_MAX_RETRIES`
|
||||||
- `HLL_HISTORICAL_REFRESH_RETRY_DELAY_SECONDS`
|
- `HLL_HISTORICAL_REFRESH_RETRY_DELAY_SECONDS`
|
||||||
|
- `HLL_HISTORICAL_WEEKLY_FALLBACK_MIN_MATCHES`
|
||||||
|
- `HLL_HISTORICAL_WEEKLY_FALLBACK_MAX_WEEKDAY`
|
||||||
|
|
||||||
Al inicializar la persistencia local, el backend normaliza tambien la identidad
|
Al inicializar la persistencia local, el backend normaliza tambien la identidad
|
||||||
historica ya guardada:
|
historica ya guardada:
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ DEFAULT_HISTORICAL_REFRESH_INTERVAL_SECONDS = 1800
|
|||||||
DEFAULT_HISTORICAL_SNAPSHOT_REFRESH_INTERVAL_SECONDS = 900
|
DEFAULT_HISTORICAL_SNAPSHOT_REFRESH_INTERVAL_SECONDS = 900
|
||||||
DEFAULT_HISTORICAL_REFRESH_MAX_RETRIES = 2
|
DEFAULT_HISTORICAL_REFRESH_MAX_RETRIES = 2
|
||||||
DEFAULT_HISTORICAL_REFRESH_RETRY_DELAY_SECONDS = 30
|
DEFAULT_HISTORICAL_REFRESH_RETRY_DELAY_SECONDS = 30
|
||||||
|
DEFAULT_HISTORICAL_WEEKLY_FALLBACK_MIN_MATCHES = 3
|
||||||
|
DEFAULT_HISTORICAL_WEEKLY_FALLBACK_MAX_WEEKDAY = 2
|
||||||
DEFAULT_ALLOWED_ORIGINS = (
|
DEFAULT_ALLOWED_ORIGINS = (
|
||||||
"null",
|
"null",
|
||||||
"http://127.0.0.1:5500",
|
"http://127.0.0.1:5500",
|
||||||
@@ -189,6 +191,32 @@ def get_historical_refresh_retry_delay_seconds() -> int:
|
|||||||
return retry_delay_seconds
|
return retry_delay_seconds
|
||||||
|
|
||||||
|
|
||||||
|
def get_historical_weekly_fallback_min_matches() -> int:
|
||||||
|
"""Return the minimum closed matches required to trust the current week."""
|
||||||
|
configured_value = os.getenv(
|
||||||
|
"HLL_HISTORICAL_WEEKLY_FALLBACK_MIN_MATCHES",
|
||||||
|
str(DEFAULT_HISTORICAL_WEEKLY_FALLBACK_MIN_MATCHES),
|
||||||
|
)
|
||||||
|
min_matches = int(configured_value)
|
||||||
|
if min_matches <= 0:
|
||||||
|
raise ValueError("HLL_HISTORICAL_WEEKLY_FALLBACK_MIN_MATCHES must be positive.")
|
||||||
|
|
||||||
|
return min_matches
|
||||||
|
|
||||||
|
|
||||||
|
def get_historical_weekly_fallback_max_weekday() -> int:
|
||||||
|
"""Return the last weekday index where weekly fallback may still apply."""
|
||||||
|
configured_value = os.getenv(
|
||||||
|
"HLL_HISTORICAL_WEEKLY_FALLBACK_MAX_WEEKDAY",
|
||||||
|
str(DEFAULT_HISTORICAL_WEEKLY_FALLBACK_MAX_WEEKDAY),
|
||||||
|
)
|
||||||
|
max_weekday = int(configured_value)
|
||||||
|
if max_weekday < 0 or max_weekday > 6:
|
||||||
|
raise ValueError("HLL_HISTORICAL_WEEKLY_FALLBACK_MAX_WEEKDAY must be between 0 and 6.")
|
||||||
|
|
||||||
|
return max_weekday
|
||||||
|
|
||||||
|
|
||||||
def get_a2s_targets_payload() -> str | None:
|
def get_a2s_targets_payload() -> str | None:
|
||||||
"""Return the optional JSON payload that overrides local A2S targets."""
|
"""Return the optional JSON payload that overrides local A2S targets."""
|
||||||
raw_payload = os.getenv(DEFAULT_A2S_TARGETS_ENV_VAR)
|
raw_payload = os.getenv(DEFAULT_A2S_TARGETS_ENV_VAR)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from .config import (
|
|||||||
get_historical_crcon_request_timeout_seconds,
|
get_historical_crcon_request_timeout_seconds,
|
||||||
get_historical_crcon_retry_delay_seconds,
|
get_historical_crcon_retry_delay_seconds,
|
||||||
)
|
)
|
||||||
|
from .historical_snapshots import generate_and_persist_historical_snapshots
|
||||||
from .historical_storage import (
|
from .historical_storage import (
|
||||||
finalize_backfill_progress,
|
finalize_backfill_progress,
|
||||||
finalize_ingestion_run,
|
finalize_ingestion_run,
|
||||||
@@ -163,6 +164,7 @@ def _run_ingestion(
|
|||||||
archive_exhausted=bool(server_stats["archive_exhausted"]),
|
archive_exhausted=bool(server_stats["archive_exhausted"]),
|
||||||
)
|
)
|
||||||
active_runs.pop(str(server["slug"]), None)
|
active_runs.pop(str(server["slug"]), None)
|
||||||
|
snapshot_result = generate_and_persist_historical_snapshots(server_key=server_slug)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
for active_server_slug, run_id in active_runs.items():
|
for active_server_slug, run_id in active_runs.items():
|
||||||
finalize_ingestion_run(
|
finalize_ingestion_run(
|
||||||
@@ -193,6 +195,7 @@ def _run_ingestion(
|
|||||||
"detail_workers": detail_workers or get_historical_crcon_detail_workers(),
|
"detail_workers": detail_workers or get_historical_crcon_detail_workers(),
|
||||||
"servers": processed_servers,
|
"servers": processed_servers,
|
||||||
"coverage": list_historical_coverage_report(server_slug=server_slug),
|
"coverage": list_historical_coverage_report(server_slug=server_slug),
|
||||||
|
"snapshot_result": snapshot_result,
|
||||||
"totals": {
|
"totals": {
|
||||||
"pages_processed": stats.pages_processed,
|
"pages_processed": stats.pages_processed,
|
||||||
"matches_seen": stats.matches_seen,
|
"matches_seen": stats.matches_seen,
|
||||||
|
|||||||
@@ -14,8 +14,7 @@ from .config import (
|
|||||||
get_historical_refresh_retry_delay_seconds,
|
get_historical_refresh_retry_delay_seconds,
|
||||||
)
|
)
|
||||||
from .historical_ingestion import run_incremental_refresh
|
from .historical_ingestion import run_incremental_refresh
|
||||||
from .historical_snapshot_storage import persist_historical_snapshot_batch
|
from .historical_snapshots import generate_and_persist_historical_snapshots
|
||||||
from .historical_snapshots import build_all_historical_snapshots
|
|
||||||
|
|
||||||
|
|
||||||
def run_periodic_historical_refresh(
|
def run_periodic_historical_refresh(
|
||||||
@@ -98,23 +97,12 @@ def generate_historical_snapshots(
|
|||||||
server_slug: str | None = None,
|
server_slug: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Build and persist precomputed snapshots for one server or all servers."""
|
"""Build and persist precomputed snapshots for one server or all servers."""
|
||||||
generated_at = datetime.now(timezone.utc)
|
result = generate_and_persist_historical_snapshots(
|
||||||
snapshots = build_all_historical_snapshots(
|
|
||||||
server_key=server_slug,
|
server_key=server_slug,
|
||||||
generated_at=generated_at,
|
generated_at=datetime.now(timezone.utc),
|
||||||
)
|
)
|
||||||
persisted_records = persist_historical_snapshot_batch(snapshots)
|
|
||||||
snapshots_by_server: dict[str, int] = {}
|
|
||||||
for record in persisted_records:
|
|
||||||
snapshots_by_server.setdefault(record.server_key, 0)
|
|
||||||
snapshots_by_server[record.server_key] += 1
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"generated_at": generated_at.isoformat().replace("+00:00", "Z"),
|
**result,
|
||||||
"server_slug": server_slug,
|
|
||||||
"snapshot_count": len(persisted_records),
|
|
||||||
"servers_processed": len(snapshots_by_server),
|
|
||||||
"snapshots_by_server": snapshots_by_server,
|
|
||||||
"refresh_interval_seconds": get_historical_refresh_interval_seconds(),
|
"refresh_interval_seconds": get_historical_refresh_interval_seconds(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -130,6 +130,40 @@ def build_all_historical_snapshots(
|
|||||||
return snapshots
|
return snapshots
|
||||||
|
|
||||||
|
|
||||||
|
def generate_and_persist_historical_snapshots(
|
||||||
|
*,
|
||||||
|
server_key: str | None = None,
|
||||||
|
generated_at: datetime | None = None,
|
||||||
|
leaderboard_limit: int = DEFAULT_WEEKLY_LEADERBOARD_LIMIT,
|
||||||
|
recent_matches_limit: int = DEFAULT_RECENT_MATCHES_LIMIT,
|
||||||
|
db_path: Path | None = None,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
"""Build and persist precomputed snapshots for one server or all servers."""
|
||||||
|
from .historical_snapshot_storage import persist_historical_snapshot_batch
|
||||||
|
|
||||||
|
generated_at_value = _as_utc(generated_at or datetime.now(timezone.utc))
|
||||||
|
snapshots = build_all_historical_snapshots(
|
||||||
|
server_key=server_key,
|
||||||
|
generated_at=generated_at_value,
|
||||||
|
leaderboard_limit=leaderboard_limit,
|
||||||
|
recent_matches_limit=recent_matches_limit,
|
||||||
|
db_path=db_path,
|
||||||
|
)
|
||||||
|
persisted_records = persist_historical_snapshot_batch(snapshots, db_path=db_path)
|
||||||
|
snapshots_by_server: dict[str, int] = {}
|
||||||
|
for record in persisted_records:
|
||||||
|
snapshots_by_server.setdefault(record.server_key, 0)
|
||||||
|
snapshots_by_server[record.server_key] += 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
"generated_at": _to_iso(generated_at_value),
|
||||||
|
"server_slug": server_key,
|
||||||
|
"snapshot_count": len(persisted_records),
|
||||||
|
"servers_processed": len(snapshots_by_server),
|
||||||
|
"snapshots_by_server": snapshots_by_server,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _build_server_summary_snapshot(
|
def _build_server_summary_snapshot(
|
||||||
server_key: str,
|
server_key: str,
|
||||||
generated_at: datetime,
|
generated_at: datetime,
|
||||||
|
|||||||
@@ -7,7 +7,11 @@ from datetime import datetime, timedelta, timezone
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Mapping
|
from typing import Mapping
|
||||||
|
|
||||||
from .config import get_storage_path
|
from .config import (
|
||||||
|
get_historical_weekly_fallback_max_weekday,
|
||||||
|
get_historical_weekly_fallback_min_matches,
|
||||||
|
get_storage_path,
|
||||||
|
)
|
||||||
from .historical_models import HistoricalServerDefinition
|
from .historical_models import HistoricalServerDefinition
|
||||||
|
|
||||||
|
|
||||||
@@ -1098,16 +1102,26 @@ def list_weekly_leaderboard(
|
|||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
"""Return ranked weekly leaderboard totals from persisted historical match stats."""
|
"""Return ranked weekly leaderboard totals from persisted historical match stats."""
|
||||||
resolved_path = initialize_historical_storage(db_path=db_path)
|
resolved_path = initialize_historical_storage(db_path=db_path)
|
||||||
window_end = datetime.now(timezone.utc)
|
current_time = datetime.now(timezone.utc)
|
||||||
window_start = window_end - timedelta(days=DEFAULT_WEEKLY_WINDOW_DAYS)
|
current_week_start = _start_of_week(current_time)
|
||||||
|
previous_week_start = current_week_start - timedelta(days=DEFAULT_WEEKLY_WINDOW_DAYS)
|
||||||
normalized_metric = metric.strip() if isinstance(metric, str) else ""
|
normalized_metric = metric.strip() if isinstance(metric, str) else ""
|
||||||
if normalized_metric not in SUPPORTED_WEEKLY_LEADERBOARD_METRICS:
|
if normalized_metric not in SUPPORTED_WEEKLY_LEADERBOARD_METRICS:
|
||||||
raise ValueError(f"Unsupported weekly leaderboard metric: {metric}")
|
raise ValueError(f"Unsupported weekly leaderboard metric: {metric}")
|
||||||
|
|
||||||
|
weekly_window = _select_weekly_window(
|
||||||
|
server_id=server_id,
|
||||||
|
current_time=current_time,
|
||||||
|
current_week_start=current_week_start,
|
||||||
|
previous_week_start=previous_week_start,
|
||||||
|
db_path=resolved_path,
|
||||||
|
)
|
||||||
|
window_start = weekly_window["window_start"]
|
||||||
|
window_end = weekly_window["window_end"]
|
||||||
where_clauses = [
|
where_clauses = [
|
||||||
"historical_matches.ended_at IS NOT NULL",
|
"historical_matches.ended_at IS NOT NULL",
|
||||||
"historical_matches.ended_at >= ?",
|
"historical_matches.ended_at >= ?",
|
||||||
"historical_matches.ended_at <= ?",
|
"historical_matches.ended_at < ?",
|
||||||
]
|
]
|
||||||
params: list[object] = [
|
params: list[object] = [
|
||||||
window_start.isoformat().replace("+00:00", "Z"),
|
window_start.isoformat().replace("+00:00", "Z"),
|
||||||
@@ -1196,6 +1210,21 @@ def list_weekly_leaderboard(
|
|||||||
"metric": normalized_metric,
|
"metric": normalized_metric,
|
||||||
"window_start": window_start.isoformat().replace("+00:00", "Z"),
|
"window_start": window_start.isoformat().replace("+00:00", "Z"),
|
||||||
"window_end": window_end.isoformat().replace("+00:00", "Z"),
|
"window_end": window_end.isoformat().replace("+00:00", "Z"),
|
||||||
|
"window_days": DEFAULT_WEEKLY_WINDOW_DAYS,
|
||||||
|
"window_kind": weekly_window["window_kind"],
|
||||||
|
"window_label": weekly_window["window_label"],
|
||||||
|
"uses_fallback": weekly_window["uses_fallback"],
|
||||||
|
"selection_reason": weekly_window["selection_reason"],
|
||||||
|
"current_week_start": current_week_start.isoformat().replace("+00:00", "Z"),
|
||||||
|
"current_week_closed_matches": weekly_window["current_week_closed_matches"],
|
||||||
|
"previous_week_closed_matches": weekly_window["previous_week_closed_matches"],
|
||||||
|
"sufficient_sample": {
|
||||||
|
"minimum_closed_matches": weekly_window["minimum_closed_matches"],
|
||||||
|
"current_week_closed_matches": weekly_window["current_week_closed_matches"],
|
||||||
|
"current_week_has_sufficient_sample": weekly_window["current_week_has_sufficient_sample"],
|
||||||
|
"is_early_week": weekly_window["is_early_week"],
|
||||||
|
"fallback_max_weekday": weekly_window["fallback_max_weekday"],
|
||||||
|
},
|
||||||
"items": items,
|
"items": items,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2112,6 +2141,105 @@ def _calculate_coverage_days(
|
|||||||
return round(delta.total_seconds() / 86400, 2)
|
return round(delta.total_seconds() / 86400, 2)
|
||||||
|
|
||||||
|
|
||||||
|
def _select_weekly_window(
|
||||||
|
*,
|
||||||
|
server_id: str | None,
|
||||||
|
current_time: datetime,
|
||||||
|
current_week_start: datetime,
|
||||||
|
previous_week_start: datetime,
|
||||||
|
db_path: Path,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
min_matches = get_historical_weekly_fallback_min_matches()
|
||||||
|
fallback_max_weekday = get_historical_weekly_fallback_max_weekday()
|
||||||
|
current_week_closed_matches = _count_closed_matches_in_window(
|
||||||
|
server_id=server_id,
|
||||||
|
window_start=current_week_start,
|
||||||
|
window_end=current_time,
|
||||||
|
db_path=db_path,
|
||||||
|
)
|
||||||
|
previous_week_closed_matches = _count_closed_matches_in_window(
|
||||||
|
server_id=server_id,
|
||||||
|
window_start=previous_week_start,
|
||||||
|
window_end=current_week_start,
|
||||||
|
db_path=db_path,
|
||||||
|
)
|
||||||
|
is_early_week = current_time.weekday() <= fallback_max_weekday
|
||||||
|
current_week_has_sufficient_sample = current_week_closed_matches >= min_matches
|
||||||
|
uses_fallback = (
|
||||||
|
is_early_week
|
||||||
|
and not current_week_has_sufficient_sample
|
||||||
|
and previous_week_closed_matches > 0
|
||||||
|
)
|
||||||
|
|
||||||
|
if uses_fallback:
|
||||||
|
return {
|
||||||
|
"window_start": previous_week_start,
|
||||||
|
"window_end": current_week_start,
|
||||||
|
"window_kind": "previous-closed-week-fallback",
|
||||||
|
"window_label": "Semana cerrada anterior",
|
||||||
|
"uses_fallback": True,
|
||||||
|
"selection_reason": "insufficient-current-week-sample",
|
||||||
|
"minimum_closed_matches": min_matches,
|
||||||
|
"current_week_closed_matches": current_week_closed_matches,
|
||||||
|
"previous_week_closed_matches": previous_week_closed_matches,
|
||||||
|
"current_week_has_sufficient_sample": False,
|
||||||
|
"is_early_week": is_early_week,
|
||||||
|
"fallback_max_weekday": fallback_max_weekday,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"window_start": current_week_start,
|
||||||
|
"window_end": current_time,
|
||||||
|
"window_kind": "current-week",
|
||||||
|
"window_label": "Semana actual",
|
||||||
|
"uses_fallback": False,
|
||||||
|
"selection_reason": "current-week",
|
||||||
|
"minimum_closed_matches": min_matches,
|
||||||
|
"current_week_closed_matches": current_week_closed_matches,
|
||||||
|
"previous_week_closed_matches": previous_week_closed_matches,
|
||||||
|
"current_week_has_sufficient_sample": current_week_has_sufficient_sample,
|
||||||
|
"is_early_week": is_early_week,
|
||||||
|
"fallback_max_weekday": fallback_max_weekday,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _count_closed_matches_in_window(
|
||||||
|
*,
|
||||||
|
server_id: str | None,
|
||||||
|
window_start: datetime,
|
||||||
|
window_end: datetime,
|
||||||
|
db_path: Path,
|
||||||
|
) -> int:
|
||||||
|
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:
|
||||||
|
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])
|
||||||
|
|
||||||
|
with _connect(db_path) as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
f"""
|
||||||
|
SELECT COUNT(DISTINCT historical_matches.id) AS matches_count
|
||||||
|
FROM historical_matches
|
||||||
|
INNER JOIN historical_servers
|
||||||
|
ON historical_servers.id = historical_matches.historical_server_id
|
||||||
|
WHERE {" AND ".join(where_clauses)}
|
||||||
|
""",
|
||||||
|
params,
|
||||||
|
).fetchone()
|
||||||
|
return int(row["matches_count"] or 0) if row is not None else 0
|
||||||
|
|
||||||
|
|
||||||
def _classify_coverage_status(
|
def _classify_coverage_status(
|
||||||
matches_count: int,
|
matches_count: int,
|
||||||
coverage_days: float | None,
|
coverage_days: float | None,
|
||||||
@@ -2125,6 +2253,12 @@ def _classify_coverage_status(
|
|||||||
return "week-plus"
|
return "week-plus"
|
||||||
|
|
||||||
|
|
||||||
|
def _start_of_week(value: datetime) -> datetime:
|
||||||
|
normalized = value.astimezone(timezone.utc)
|
||||||
|
midnight = normalized.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
return midnight - timedelta(days=midnight.weekday())
|
||||||
|
|
||||||
|
|
||||||
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:
|
||||||
|
|||||||
@@ -240,10 +240,18 @@ def build_weekly_leaderboard_payload(
|
|||||||
"title": title_by_metric.get(metric, "Ranking semanal por servidor"),
|
"title": title_by_metric.get(metric, "Ranking semanal por servidor"),
|
||||||
"context": "historical-weekly-leaderboard",
|
"context": "historical-weekly-leaderboard",
|
||||||
"metric": metric,
|
"metric": metric,
|
||||||
"summary_basis": "closed-matches-last-7-days",
|
"summary_basis": "closed-matches-calendar-week",
|
||||||
"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"],
|
||||||
|
"window_kind": result.get("window_kind"),
|
||||||
|
"window_label": result.get("window_label"),
|
||||||
|
"uses_fallback": bool(result.get("uses_fallback")),
|
||||||
|
"selection_reason": result.get("selection_reason"),
|
||||||
|
"current_week_start": result.get("current_week_start"),
|
||||||
|
"current_week_closed_matches": result.get("current_week_closed_matches"),
|
||||||
|
"previous_week_closed_matches": result.get("previous_week_closed_matches"),
|
||||||
|
"sufficient_sample": result.get("sufficient_sample"),
|
||||||
"limit": limit,
|
"limit": limit,
|
||||||
"items": result["items"],
|
"items": result["items"],
|
||||||
},
|
},
|
||||||
@@ -331,6 +339,18 @@ def build_weekly_leaderboard_snapshot_payload(
|
|||||||
"window_days": payload.get("window_days") if isinstance(payload, dict) else 7,
|
"window_days": payload.get("window_days") if isinstance(payload, dict) else 7,
|
||||||
"window_start": payload.get("window_start") if isinstance(payload, dict) else None,
|
"window_start": payload.get("window_start") if isinstance(payload, dict) else None,
|
||||||
"window_end": payload.get("window_end") if isinstance(payload, dict) else None,
|
"window_end": payload.get("window_end") if isinstance(payload, dict) else None,
|
||||||
|
"window_kind": payload.get("window_kind") if isinstance(payload, dict) else None,
|
||||||
|
"window_label": payload.get("window_label") if isinstance(payload, dict) else None,
|
||||||
|
"uses_fallback": bool(payload.get("uses_fallback")) if isinstance(payload, dict) else False,
|
||||||
|
"selection_reason": payload.get("selection_reason") if isinstance(payload, dict) else None,
|
||||||
|
"current_week_start": payload.get("current_week_start") if isinstance(payload, dict) else None,
|
||||||
|
"current_week_closed_matches": (
|
||||||
|
payload.get("current_week_closed_matches") if isinstance(payload, dict) else None
|
||||||
|
),
|
||||||
|
"previous_week_closed_matches": (
|
||||||
|
payload.get("previous_week_closed_matches") 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,
|
||||||
"items": sliced_items,
|
"items": sliced_items,
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ const DEFAULT_HISTORICAL_SERVER = HISTORICAL_SERVER_SLUGS[0];
|
|||||||
const LEADERBOARD_METRICS = Object.freeze([
|
const LEADERBOARD_METRICS = Object.freeze([
|
||||||
{
|
{
|
||||||
key: "kills",
|
key: "kills",
|
||||||
title: "Top kills de los ultimos 7 dias",
|
title: "Top kills semanales",
|
||||||
valueHeading: "Kills",
|
valueHeading: "Kills",
|
||||||
emptyMessage: "Sin datos historicos suficientes para mostrar el top de kills semanal.",
|
emptyMessage: "Sin datos historicos suficientes para mostrar el top de kills semanal.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "deaths",
|
key: "deaths",
|
||||||
title: "Top muertes de los ultimos 7 dias",
|
title: "Top muertes semanales",
|
||||||
valueHeading: "Muertes",
|
valueHeading: "Muertes",
|
||||||
emptyMessage: "Sin datos historicos suficientes para mostrar el top de muertes semanal.",
|
emptyMessage: "Sin datos historicos suficientes para mostrar el top de muertes semanal.",
|
||||||
},
|
},
|
||||||
@@ -24,7 +24,7 @@ const LEADERBOARD_METRICS = Object.freeze([
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "support",
|
key: "support",
|
||||||
title: "Top puntos de soporte de los ultimos 7 dias",
|
title: "Top puntos de soporte semanales",
|
||||||
valueHeading: "Soporte",
|
valueHeading: "Soporte",
|
||||||
emptyMessage: "Sin datos historicos suficientes para mostrar el top de soporte semanal.",
|
emptyMessage: "Sin datos historicos suficientes para mostrar el top de soporte semanal.",
|
||||||
},
|
},
|
||||||
@@ -593,9 +593,17 @@ function buildWeeklyWindowNote(payload) {
|
|||||||
|
|
||||||
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 windowDays = Number(payload?.window_days);
|
const windowLabel = payload?.window_label || "Semana activa";
|
||||||
const daysLabel = Number.isFinite(windowDays) ? `${windowDays} dias` : "7 dias";
|
if (payload?.uses_fallback) {
|
||||||
return `Ranking servido desde snapshot semanal de ${daysLabel}: ${start} a ${end}.`;
|
const minimumMatches =
|
||||||
|
payload?.sufficient_sample?.minimum_closed_matches || payload?.minimum_closed_matches || 0;
|
||||||
|
const currentWeekMatches =
|
||||||
|
payload?.current_week_closed_matches ??
|
||||||
|
payload?.sufficient_sample?.current_week_closed_matches ??
|
||||||
|
0;
|
||||||
|
return `${windowLabel}: ${start} a ${end}. Se muestra temporalmente porque la semana actual solo tiene ${formatNumber(currentWeekMatches)} cierres y el minimo operativo es ${formatNumber(minimumMatches)}.`;
|
||||||
|
}
|
||||||
|
return `${windowLabel}: ${start} a ${end}.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSnapshotMetaText(payload, missingMessage) {
|
function buildSnapshotMetaText(payload, missingMessage) {
|
||||||
|
|||||||
Reference in New Issue
Block a user