Complete historical coverage and UI review tasks
This commit is contained in:
@@ -0,0 +1,72 @@
|
|||||||
|
# TASK-033-historical-full-bootstrap-and-coverage-validation
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Ejecutar y consolidar una carga histórica completa real para los 2 servidores de la comunidad, validando la cobertura temporal y cuantitativa del histórico persistido para asegurar que la base histórica sea suficientemente representativa antes de seguir refinando la UI y las métricas.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
La capa histórica ya existe y funciona, pero el estado actual del proyecto indica que el histórico persistido parece insuficiente para representar con credibilidad una semana completa de actividad. La UI actual muestra resúmenes basados en lo que haya cargado en la base, y si la cobertura es corta puede inducir a interpretar mal el estado del histórico. Antes de seguir afinando la capa histórica, hace falta asegurar un bootstrap real y comprobar la cobertura conseguida para ambos servidores.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
1. Revisar la implementación actual de bootstrap, refresh incremental y persistencia histórica.
|
||||||
|
2. Confirmar si el histórico actual está subpoblado por haber usado refrescos parciales, límites de páginas, validaciones locales u otras restricciones.
|
||||||
|
3. Ejecutar o dejar implementado el flujo de bootstrap completo real para ambos servidores de la comunidad, sin limitar la carga a una ventana artificialmente corta.
|
||||||
|
4. Persistir el histórico completo disponible desde la fuente CRCON scoreboard JSON para ambos servidores.
|
||||||
|
5. Verificar y documentar, por servidor:
|
||||||
|
- número total de partidas históricas persistidas
|
||||||
|
- número de jugadores únicos
|
||||||
|
- rango temporal cubierto
|
||||||
|
- fecha/hora de primera partida persistida
|
||||||
|
- fecha/hora de última partida persistida
|
||||||
|
6. Validar que la persistencia sigue siendo idempotente tras el bootstrap completo.
|
||||||
|
7. Detectar si hay límites reales de origen (por ejemplo, datos antiguos no disponibles ya en la fuente) y documentarlos claramente.
|
||||||
|
8. Revisar si hace falta ajustar el flujo bootstrap para que sea operativamente reproducible y comprensible.
|
||||||
|
9. Documentar el estado real de cobertura alcanzado tras este bootstrap.
|
||||||
|
10. No crear todavía UI histórica nueva en esta task.
|
||||||
|
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
|
||||||
|
- ai/repo-context.md
|
||||||
|
- ai/architecture-index.md
|
||||||
|
- docs/historical-crcon-source-discovery.md
|
||||||
|
- docs/historical-domain-model.md
|
||||||
|
- docs/historical-data-quality-notes.md
|
||||||
|
- backend/README.md
|
||||||
|
- backend/app/config.py
|
||||||
|
- backend/app/historical_ingestion.py
|
||||||
|
- backend/app/historical_storage.py
|
||||||
|
- backend/app/historical_models.py
|
||||||
|
- backend/app/routes.py
|
||||||
|
- backend/app/payloads.py
|
||||||
|
|
||||||
|
## Expected Files to Modify
|
||||||
|
- backend/README.md
|
||||||
|
- backend/app/historical_ingestion.py
|
||||||
|
- backend/app/historical_storage.py
|
||||||
|
- backend/app/config.py
|
||||||
|
- opcionalmente nuevos módulos auxiliares si son necesarios para consolidar bootstrap y validación de cobertura
|
||||||
|
- un documento técnico nuevo o actualizado, por ejemplo:
|
||||||
|
- docs/historical-coverage-report.md
|
||||||
|
- docs/historical-data-quality-notes.md
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
- No basar el histórico en A2S.
|
||||||
|
- No crear UI histórica nueva en esta task.
|
||||||
|
- No depender del HTML público de `/games` como fuente final.
|
||||||
|
- No romper el flujo actual de live status.
|
||||||
|
- No hacer cambios destructivos.
|
||||||
|
- Mantener el trabajo centrado en bootstrap completo, cobertura e idempotencia.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
- Existe un bootstrap histórico completo real para ambos servidores.
|
||||||
|
- La cobertura histórica real queda medida y documentada.
|
||||||
|
- El histórico persistido contiene un volumen y rango temporal coherentes con la disponibilidad real de la fuente.
|
||||||
|
- La persistencia sigue siendo idempotente.
|
||||||
|
- 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.
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# TASK-034-historical-summary-semantics-and-coverage-badging
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Corregir la semántica del resumen histórico y de los indicadores visibles para que la UI y la API distingan claramente entre cobertura histórica importada y ventana temporal semanal, evitando interpretaciones erróneas como asumir que un número bajo de partidas representa una semana completa de actividad.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
La UI histórica actual puede llevar a confusión porque el usuario puede interpretar ciertos resúmenes como si describieran una semana completa, cuando en realidad reflejan solo la cobertura actualmente persistida en base. Aunque el ranking semanal y el resumen de servidor son conceptos distintos, hoy esa diferencia no queda visual ni semánticamente lo bastante clara.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
1. Revisar el payload y la lógica actual del resumen histórico por servidor.
|
||||||
|
2. Revisar qué información muestra hoy la UI histórica sobre:
|
||||||
|
- cobertura temporal
|
||||||
|
- número de partidas
|
||||||
|
- rango de fechas
|
||||||
|
- ranking semanal
|
||||||
|
3. Definir una semántica clara para distinguir:
|
||||||
|
- cobertura histórica importada
|
||||||
|
- ventana semanal usada para rankings
|
||||||
|
- resumen agregado del servidor
|
||||||
|
4. Ajustar el backend para exponer, si hace falta, campos más claros sobre cobertura histórica real, por ejemplo:
|
||||||
|
- first_match_at
|
||||||
|
- last_match_at
|
||||||
|
- imported_matches_count
|
||||||
|
- coverage_status
|
||||||
|
- cualquier otro metadato útil y honesto
|
||||||
|
5. Ajustar la UI para que el usuario entienda correctamente:
|
||||||
|
- qué parte es “últimos 7 días”
|
||||||
|
- qué parte es “cobertura total importada”
|
||||||
|
- cuándo la cobertura es parcial o insuficiente
|
||||||
|
6. Eliminar formulaciones ambiguas o visualmente engañosas.
|
||||||
|
7. Mantener la estética y coherencia de la UI histórica.
|
||||||
|
8. No abrir todavía nuevas grandes vistas históricas.
|
||||||
|
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
|
||||||
|
- ai/repo-context.md
|
||||||
|
- ai/architecture-index.md
|
||||||
|
- docs/historical-data-quality-notes.md
|
||||||
|
- docs/historical-coverage-report.md
|
||||||
|
- backend/README.md
|
||||||
|
- backend/app/routes.py
|
||||||
|
- backend/app/payloads.py
|
||||||
|
- backend/app/historical_storage.py
|
||||||
|
- frontend/historico.html
|
||||||
|
- frontend/assets/js/historico.js
|
||||||
|
- frontend/assets/css/historico.css
|
||||||
|
|
||||||
|
## Expected Files to Modify
|
||||||
|
- backend/app/routes.py
|
||||||
|
- backend/app/payloads.py
|
||||||
|
- backend/app/historical_storage.py
|
||||||
|
- frontend/historico.html
|
||||||
|
- frontend/assets/js/historico.js
|
||||||
|
- frontend/assets/css/historico.css
|
||||||
|
- opcionalmente backend/README.md o documentación técnica mínima si hace falta reflejar la nueva semántica
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
- No crear páginas usando la URL de la comunidad.
|
||||||
|
- No depender de HTML externo.
|
||||||
|
- No romper la UI histórica existente.
|
||||||
|
- No romper el ranking semanal.
|
||||||
|
- No hacer cambios destructivos.
|
||||||
|
- Mantener el trabajo centrado en claridad semántica, payload y UI.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
- La UI ya no induce a interpretar mal la cobertura histórica.
|
||||||
|
- Queda clara la diferencia entre cobertura importada y ranking de la última semana.
|
||||||
|
- El resumen del servidor es más honesto y comprensible.
|
||||||
|
- El backend expone metadatos suficientes para soportar esa claridad.
|
||||||
|
- Los cambios quedan committeados y se hace push al remoto si el entorno lo permite.
|
||||||
|
|
||||||
|
## Change Budget
|
||||||
|
- Preferir menos de 6 archivos modificados o creados.
|
||||||
|
- Preferir menos de 220 líneas cambiadas.
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# TASK-035-historical-ui-after-bootstrap-review
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Revisar y ajustar la UI histórica propia del proyecto una vez que el bootstrap histórico completo y la semántica de cobertura estén resueltos, para asegurar que el resultado final sea visualmente claro, útil y coherente con el resto del producto.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
La UI histórica ya existe, pero fue construida antes de confirmar que la cobertura histórica completa estuviera realmente cargada y antes de clarificar suficientemente la semántica entre resumen y ranking semanal. Una vez el histórico esté bien poblado y la capa semántica corregida, hace falta una pasada de revisión específica sobre la experiencia visual e informativa de la página histórica.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
1. Revisar la UI histórica actual después del bootstrap completo y de los ajustes de semántica/cobertura.
|
||||||
|
2. Validar el comportamiento visual y de contenido de:
|
||||||
|
- resumen de servidor
|
||||||
|
- ranking semanal
|
||||||
|
- selector de servidor
|
||||||
|
- estado vacío/error/carga
|
||||||
|
- cualquier otro bloque histórico ya presente
|
||||||
|
3. Comprobar si el volumen real de datos cambia la lectura de la interfaz y obliga a reajustar:
|
||||||
|
- textos
|
||||||
|
- jerarquía
|
||||||
|
- espaciado
|
||||||
|
- orden de secciones
|
||||||
|
- presentación de métricas
|
||||||
|
4. Corregir defectos pequeños o medianos detectados en esta revisión.
|
||||||
|
5. Asegurar que la UI histórica:
|
||||||
|
- sea comprensible
|
||||||
|
- no sobreinterprete los datos
|
||||||
|
- mantenga coherencia visual con la landing
|
||||||
|
6. No abrir todavía nuevas features históricas grandes en esta task.
|
||||||
|
7. 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
|
||||||
|
- ai/repo-context.md
|
||||||
|
- ai/architecture-index.md
|
||||||
|
- docs/historical-data-quality-notes.md
|
||||||
|
- docs/historical-coverage-report.md
|
||||||
|
- backend/app/routes.py
|
||||||
|
- backend/app/payloads.py
|
||||||
|
- frontend/historico.html
|
||||||
|
- frontend/assets/js/historico.js
|
||||||
|
- frontend/assets/css/historico.css
|
||||||
|
- frontend/index.html
|
||||||
|
- frontend/assets/css/styles.css
|
||||||
|
|
||||||
|
## Expected Files to Modify
|
||||||
|
- frontend/historico.html
|
||||||
|
- frontend/assets/js/historico.js
|
||||||
|
- frontend/assets/css/historico.css
|
||||||
|
- opcionalmente frontend/index.html o frontend/assets/css/styles.css si hace falta un ajuste menor de acceso o coherencia visual
|
||||||
|
- opcionalmente documentación mínima si algún comportamiento visible necesita quedar reflejado
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
- No crear nuevas páginas basadas en URLs externas.
|
||||||
|
- No abrir nuevas grandes features históricas.
|
||||||
|
- No romper la UI histórica existente.
|
||||||
|
- No hacer cambios destructivos.
|
||||||
|
- Mantener el trabajo centrado en revisión final, claridad y pulido después del bootstrap real.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
- La UI histórica refleja correctamente el histórico ya poblado.
|
||||||
|
- La presentación del resumen y del ranking es clara.
|
||||||
|
- La experiencia visual es coherente con el resto del proyecto.
|
||||||
|
- No se detectan malentendidos graves entre cobertura histórica y ranking semanal.
|
||||||
|
- Los cambios quedan committeados y se hace push al remoto si el entorno lo permite.
|
||||||
|
|
||||||
|
## Change Budget
|
||||||
|
- Preferir menos de 5 archivos modificados o creados.
|
||||||
|
- Preferir menos de 200 líneas cambiadas.
|
||||||
@@ -66,6 +66,7 @@ Variables opcionales:
|
|||||||
- `HLL_BACKEND_REFRESH_INTERVAL_SECONDS`
|
- `HLL_BACKEND_REFRESH_INTERVAL_SECONDS`
|
||||||
- `HLL_HISTORICAL_CRCON_PAGE_SIZE`
|
- `HLL_HISTORICAL_CRCON_PAGE_SIZE`
|
||||||
- `HLL_HISTORICAL_CRCON_TIMEOUT_SECONDS`
|
- `HLL_HISTORICAL_CRCON_TIMEOUT_SECONDS`
|
||||||
|
- `HLL_HISTORICAL_CRCON_DETAIL_WORKERS`
|
||||||
- `HLL_HISTORICAL_REFRESH_INTERVAL_SECONDS`
|
- `HLL_HISTORICAL_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`
|
||||||
@@ -470,12 +471,31 @@ Flags utiles:
|
|||||||
- `--server comunidad-hispana-01` para limitar a un servidor
|
- `--server comunidad-hispana-01` para limitar a un servidor
|
||||||
- `--max-pages 2` para validacion local acotada
|
- `--max-pages 2` para validacion local acotada
|
||||||
- `--page-size 25` para ajustar paginacion
|
- `--page-size 25` para ajustar paginacion
|
||||||
|
- `--start-page 4` para reanudar desde una pagina concreta en bootstraps largos
|
||||||
|
- `--detail-workers 16` para paralelizar el detalle por partida
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
|
El comando devuelve ademas un resumen de cobertura persistida por servidor. Esto
|
||||||
|
ayuda a validar rapidamente cuantos matches reales quedaron importados, el rango
|
||||||
|
temporal cubierto y si la carga ya supera la ultima semana movil que usa la UI.
|
||||||
|
|
||||||
|
Como la fuente CRCON publica expone un archivo muy profundo y puede devolver
|
||||||
|
errores `502` intermitentes bajo carga sostenida, el bootstrap completo debe
|
||||||
|
tratarse como una operacion reanudable. Flujo recomendado:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python -m app.historical_ingestion bootstrap --detail-workers 16
|
||||||
|
python -m app.historical_ingestion bootstrap --start-page 4 --detail-workers 16
|
||||||
|
```
|
||||||
|
|
||||||
|
La segunda invocacion permite continuar desde la siguiente pagina pendiente si
|
||||||
|
la sesion anterior se corta por tiempo disponible o por inestabilidad puntual
|
||||||
|
del origen.
|
||||||
|
|
||||||
El runner `python -m app.historical_runner` deja ese refresh incremental listo
|
El runner `python -m app.historical_runner` deja ese refresh incremental listo
|
||||||
para ejecucion local repetida sin depender de infraestructura externa. Por
|
para ejecucion local repetida sin depender de infraestructura externa. Por
|
||||||
defecto:
|
defecto:
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ DEFAULT_STORAGE_FILENAME = "hll_vietnam_dev.sqlite3"
|
|||||||
DEFAULT_REFRESH_INTERVAL_SECONDS = 120
|
DEFAULT_REFRESH_INTERVAL_SECONDS = 120
|
||||||
DEFAULT_HISTORICAL_CRCON_PAGE_SIZE = 50
|
DEFAULT_HISTORICAL_CRCON_PAGE_SIZE = 50
|
||||||
DEFAULT_HISTORICAL_CRCON_TIMEOUT_SECONDS = 15.0
|
DEFAULT_HISTORICAL_CRCON_TIMEOUT_SECONDS = 15.0
|
||||||
|
DEFAULT_HISTORICAL_CRCON_DETAIL_WORKERS = 8
|
||||||
DEFAULT_HISTORICAL_REFRESH_INTERVAL_SECONDS = 1800
|
DEFAULT_HISTORICAL_REFRESH_INTERVAL_SECONDS = 1800
|
||||||
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
|
||||||
@@ -98,6 +99,19 @@ def get_historical_crcon_request_timeout_seconds() -> float:
|
|||||||
return timeout_seconds
|
return timeout_seconds
|
||||||
|
|
||||||
|
|
||||||
|
def get_historical_crcon_detail_workers() -> int:
|
||||||
|
"""Return the worker count used for CRCON historical detail requests."""
|
||||||
|
configured_value = os.getenv(
|
||||||
|
"HLL_HISTORICAL_CRCON_DETAIL_WORKERS",
|
||||||
|
str(DEFAULT_HISTORICAL_CRCON_DETAIL_WORKERS),
|
||||||
|
)
|
||||||
|
worker_count = int(configured_value)
|
||||||
|
if worker_count <= 0:
|
||||||
|
raise ValueError("HLL_HISTORICAL_CRCON_DETAIL_WORKERS must be positive.")
|
||||||
|
|
||||||
|
return worker_count
|
||||||
|
|
||||||
|
|
||||||
def get_historical_refresh_interval_seconds() -> int:
|
def get_historical_refresh_interval_seconds() -> int:
|
||||||
"""Return the default interval used by the historical refresh loop."""
|
"""Return the default interval used by the historical refresh loop."""
|
||||||
configured_value = os.getenv(
|
configured_value = os.getenv(
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
|
import time
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Iterable
|
from typing import Iterable
|
||||||
from urllib.error import HTTPError, URLError
|
from urllib.error import HTTPError, URLError
|
||||||
@@ -11,6 +13,7 @@ from urllib.parse import urlencode
|
|||||||
from urllib.request import Request, urlopen
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
from .config import (
|
from .config import (
|
||||||
|
get_historical_crcon_detail_workers,
|
||||||
get_historical_crcon_page_size,
|
get_historical_crcon_page_size,
|
||||||
get_historical_crcon_request_timeout_seconds,
|
get_historical_crcon_request_timeout_seconds,
|
||||||
)
|
)
|
||||||
@@ -18,6 +21,7 @@ from .historical_storage import (
|
|||||||
finalize_ingestion_run,
|
finalize_ingestion_run,
|
||||||
get_refresh_cutoff_for_server,
|
get_refresh_cutoff_for_server,
|
||||||
initialize_historical_storage,
|
initialize_historical_storage,
|
||||||
|
list_historical_coverage_report,
|
||||||
list_historical_servers,
|
list_historical_servers,
|
||||||
start_ingestion_run,
|
start_ingestion_run,
|
||||||
upsert_historical_match,
|
upsert_historical_match,
|
||||||
@@ -27,6 +31,7 @@ from .historical_storage import (
|
|||||||
PUBLIC_INFO_ENDPOINT = "/api/get_public_info"
|
PUBLIC_INFO_ENDPOINT = "/api/get_public_info"
|
||||||
MATCH_LIST_ENDPOINT = "/api/get_scoreboard_maps"
|
MATCH_LIST_ENDPOINT = "/api/get_scoreboard_maps"
|
||||||
MATCH_DETAIL_ENDPOINT = "/api/get_map_scoreboard"
|
MATCH_DETAIL_ENDPOINT = "/api/get_map_scoreboard"
|
||||||
|
DEFAULT_DETAIL_FETCH_RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
@@ -52,6 +57,8 @@ def run_bootstrap(
|
|||||||
server_slug: str | None = None,
|
server_slug: str | None = None,
|
||||||
max_pages: int | None = None,
|
max_pages: int | None = None,
|
||||||
page_size: int | None = None,
|
page_size: int | None = None,
|
||||||
|
start_page: int = 1,
|
||||||
|
detail_workers: int | None = None,
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
"""Run a first full historical import against one or all configured servers."""
|
"""Run a first full historical import against one or all configured servers."""
|
||||||
return _run_ingestion(
|
return _run_ingestion(
|
||||||
@@ -59,6 +66,8 @@ def run_bootstrap(
|
|||||||
server_slug=server_slug,
|
server_slug=server_slug,
|
||||||
max_pages=max_pages,
|
max_pages=max_pages,
|
||||||
page_size=page_size,
|
page_size=page_size,
|
||||||
|
start_page=start_page,
|
||||||
|
detail_workers=detail_workers,
|
||||||
incremental=False,
|
incremental=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -68,6 +77,8 @@ def run_incremental_refresh(
|
|||||||
server_slug: str | None = None,
|
server_slug: str | None = None,
|
||||||
max_pages: int | None = None,
|
max_pages: int | None = None,
|
||||||
page_size: int | None = None,
|
page_size: int | None = None,
|
||||||
|
start_page: int = 1,
|
||||||
|
detail_workers: int | None = None,
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
"""Refresh recent historical pages without replaying the whole archive."""
|
"""Refresh recent historical pages without replaying the whole archive."""
|
||||||
return _run_ingestion(
|
return _run_ingestion(
|
||||||
@@ -75,6 +86,8 @@ def run_incremental_refresh(
|
|||||||
server_slug=server_slug,
|
server_slug=server_slug,
|
||||||
max_pages=max_pages,
|
max_pages=max_pages,
|
||||||
page_size=page_size,
|
page_size=page_size,
|
||||||
|
start_page=start_page,
|
||||||
|
detail_workers=detail_workers,
|
||||||
incremental=True,
|
incremental=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -85,6 +98,8 @@ def _run_ingestion(
|
|||||||
server_slug: str | None,
|
server_slug: str | None,
|
||||||
max_pages: int | None,
|
max_pages: int | None,
|
||||||
page_size: int | None,
|
page_size: int | None,
|
||||||
|
start_page: int,
|
||||||
|
detail_workers: int | None,
|
||||||
incremental: bool,
|
incremental: bool,
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
initialize_historical_storage()
|
initialize_historical_storage()
|
||||||
@@ -107,6 +122,8 @@ def _run_ingestion(
|
|||||||
stats=stats,
|
stats=stats,
|
||||||
max_pages=max_pages,
|
max_pages=max_pages,
|
||||||
page_size=page_size,
|
page_size=page_size,
|
||||||
|
start_page=start_page,
|
||||||
|
detail_workers=detail_workers,
|
||||||
cutoff=cutoff,
|
cutoff=cutoff,
|
||||||
)
|
)
|
||||||
processed_servers.append(server_stats)
|
processed_servers.append(server_stats)
|
||||||
@@ -140,7 +157,10 @@ def _run_ingestion(
|
|||||||
"status": "ok",
|
"status": "ok",
|
||||||
"mode": mode,
|
"mode": mode,
|
||||||
"page_size": page_size or get_historical_crcon_page_size(),
|
"page_size": page_size or get_historical_crcon_page_size(),
|
||||||
|
"start_page": start_page,
|
||||||
|
"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),
|
||||||
"totals": {
|
"totals": {
|
||||||
"pages_processed": stats.pages_processed,
|
"pages_processed": stats.pages_processed,
|
||||||
"matches_seen": stats.matches_seen,
|
"matches_seen": stats.matches_seen,
|
||||||
@@ -158,26 +178,36 @@ def _ingest_server(
|
|||||||
stats: IngestionStats,
|
stats: IngestionStats,
|
||||||
max_pages: int | None,
|
max_pages: int | None,
|
||||||
page_size: int | None,
|
page_size: int | None,
|
||||||
|
start_page: int,
|
||||||
|
detail_workers: int | None,
|
||||||
cutoff: str | None,
|
cutoff: str | None,
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
resolved_page_size = page_size or get_historical_crcon_page_size()
|
resolved_page_size = page_size or get_historical_crcon_page_size()
|
||||||
|
resolved_detail_workers = detail_workers or get_historical_crcon_detail_workers()
|
||||||
page_limit = max_pages or 1000000
|
page_limit = max_pages or 1000000
|
||||||
|
start_page = max(1, start_page)
|
||||||
local_stats = IngestionStats()
|
local_stats = IngestionStats()
|
||||||
public_info = _fetch_public_info(str(server["scoreboard_base_url"]))
|
public_info = _fetch_public_info(str(server["scoreboard_base_url"]))
|
||||||
|
discovered_total_matches: int | None = None
|
||||||
|
last_page_processed: int | None = None
|
||||||
|
|
||||||
for page_number in range(1, page_limit + 1):
|
for page_number in range(start_page, start_page + page_limit):
|
||||||
payload = _fetch_match_page(
|
payload = _fetch_match_page(
|
||||||
str(server["scoreboard_base_url"]),
|
str(server["scoreboard_base_url"]),
|
||||||
page=page_number,
|
page=page_number,
|
||||||
limit=resolved_page_size,
|
limit=resolved_page_size,
|
||||||
)
|
)
|
||||||
|
if discovered_total_matches is None:
|
||||||
|
discovered_total_matches = _coerce_int(payload.get("total"))
|
||||||
page_matches = _coerce_match_list(payload.get("maps"))
|
page_matches = _coerce_match_list(payload.get("maps"))
|
||||||
if not page_matches:
|
if not page_matches:
|
||||||
break
|
break
|
||||||
|
|
||||||
local_stats.pages_processed += 1
|
local_stats.pages_processed += 1
|
||||||
stats.pages_processed += 1
|
stats.pages_processed += 1
|
||||||
|
last_page_processed = page_number
|
||||||
stop_after_page = False
|
stop_after_page = False
|
||||||
|
match_ids_to_fetch: list[str] = []
|
||||||
|
|
||||||
for match_summary in page_matches:
|
for match_summary in page_matches:
|
||||||
local_stats.matches_seen += 1
|
local_stats.matches_seen += 1
|
||||||
@@ -188,10 +218,15 @@ def _ingest_server(
|
|||||||
stop_after_page = True
|
stop_after_page = True
|
||||||
continue
|
continue
|
||||||
|
|
||||||
detail_payload = _fetch_match_detail(
|
match_id = _stringify(match_summary.get("id"))
|
||||||
str(server["scoreboard_base_url"]),
|
if match_id:
|
||||||
match_id=str(match_summary["id"]),
|
match_ids_to_fetch.append(match_id)
|
||||||
)
|
|
||||||
|
for detail_payload in _fetch_match_details(
|
||||||
|
str(server["scoreboard_base_url"]),
|
||||||
|
match_ids_to_fetch,
|
||||||
|
max_workers=resolved_detail_workers,
|
||||||
|
):
|
||||||
delta = upsert_historical_match(
|
delta = upsert_historical_match(
|
||||||
server_slug=str(server["slug"]),
|
server_slug=str(server["slug"]),
|
||||||
match_payload=detail_payload,
|
match_payload=detail_payload,
|
||||||
@@ -208,10 +243,13 @@ def _ingest_server(
|
|||||||
"server_number": public_info.get("server_number") or server.get("server_number"),
|
"server_number": public_info.get("server_number") or server.get("server_number"),
|
||||||
"pages_processed": local_stats.pages_processed,
|
"pages_processed": local_stats.pages_processed,
|
||||||
"matches_seen": local_stats.matches_seen,
|
"matches_seen": local_stats.matches_seen,
|
||||||
|
"discovered_total_matches": discovered_total_matches,
|
||||||
"matches_inserted": local_stats.matches_inserted,
|
"matches_inserted": local_stats.matches_inserted,
|
||||||
"matches_updated": local_stats.matches_updated,
|
"matches_updated": local_stats.matches_updated,
|
||||||
"player_rows_inserted": local_stats.player_rows_inserted,
|
"player_rows_inserted": local_stats.player_rows_inserted,
|
||||||
"player_rows_updated": local_stats.player_rows_updated,
|
"player_rows_updated": local_stats.player_rows_updated,
|
||||||
|
"start_page": start_page,
|
||||||
|
"last_page_processed": last_page_processed,
|
||||||
"cutoff": cutoff,
|
"cutoff": cutoff,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,32 +267,47 @@ def _select_servers(server_slug: str | None) -> list[dict[str, object]]:
|
|||||||
|
|
||||||
|
|
||||||
def _fetch_public_info(base_url: str) -> dict[str, object]:
|
def _fetch_public_info(base_url: str) -> dict[str, object]:
|
||||||
payload = _unwrap_result(_fetch_json(base_url, PUBLIC_INFO_ENDPOINT))
|
return _fetch_dict_payload(base_url, PUBLIC_INFO_ENDPOINT)
|
||||||
if not isinstance(payload, dict):
|
|
||||||
raise ValueError(f"Unexpected public info payload for {base_url}")
|
|
||||||
return payload
|
|
||||||
|
|
||||||
|
|
||||||
def _fetch_match_page(base_url: str, *, page: int, limit: int) -> dict[str, object]:
|
def _fetch_match_page(base_url: str, *, page: int, limit: int) -> dict[str, object]:
|
||||||
payload = _unwrap_result(_fetch_json(
|
return _fetch_dict_payload(
|
||||||
base_url,
|
base_url,
|
||||||
MATCH_LIST_ENDPOINT,
|
MATCH_LIST_ENDPOINT,
|
||||||
{"page": page, "limit": limit},
|
{"page": page, "limit": limit},
|
||||||
))
|
context=f"page={page}",
|
||||||
if not isinstance(payload, dict):
|
)
|
||||||
raise ValueError(f"Unexpected match list payload for {base_url} page={page}")
|
|
||||||
return payload
|
|
||||||
|
|
||||||
|
|
||||||
def _fetch_match_detail(base_url: str, *, match_id: str) -> dict[str, object]:
|
def _fetch_match_detail(base_url: str, *, match_id: str) -> dict[str, object]:
|
||||||
payload = _unwrap_result(_fetch_json(
|
return _fetch_dict_payload(
|
||||||
base_url,
|
base_url,
|
||||||
MATCH_DETAIL_ENDPOINT,
|
MATCH_DETAIL_ENDPOINT,
|
||||||
{"map_id": match_id},
|
{"map_id": match_id},
|
||||||
))
|
context=f"match={match_id}",
|
||||||
if not isinstance(payload, dict):
|
)
|
||||||
raise ValueError(f"Unexpected match detail payload for {base_url} match={match_id}")
|
|
||||||
return payload
|
|
||||||
|
def _fetch_match_details(
|
||||||
|
base_url: str,
|
||||||
|
match_ids: list[str],
|
||||||
|
*,
|
||||||
|
max_workers: int,
|
||||||
|
) -> list[dict[str, object]]:
|
||||||
|
if not match_ids:
|
||||||
|
return []
|
||||||
|
if max_workers <= 1:
|
||||||
|
return [
|
||||||
|
_fetch_match_detail(base_url, match_id=match_id)
|
||||||
|
for match_id in match_ids
|
||||||
|
]
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||||
|
futures = [
|
||||||
|
executor.submit(_fetch_match_detail, base_url, match_id=match_id)
|
||||||
|
for match_id in match_ids
|
||||||
|
]
|
||||||
|
return [future.result() for future in futures]
|
||||||
|
|
||||||
|
|
||||||
def _fetch_json(
|
def _fetch_json(
|
||||||
@@ -285,6 +338,34 @@ def _fetch_json(
|
|||||||
raise RuntimeError(f"Historical CRCON request failed: {url} ({exc.reason})") from exc
|
raise RuntimeError(f"Historical CRCON request failed: {url} ({exc.reason})") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_dict_payload(
|
||||||
|
base_url: str,
|
||||||
|
endpoint: str,
|
||||||
|
query: dict[str, object] | None = None,
|
||||||
|
*,
|
||||||
|
context: str = "",
|
||||||
|
retries: int = DEFAULT_DETAIL_FETCH_RETRIES,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
last_error: Exception | None = None
|
||||||
|
for attempt in range(1, retries + 1):
|
||||||
|
try:
|
||||||
|
payload = _unwrap_result(_fetch_json(base_url, endpoint, query))
|
||||||
|
except Exception as exc: # pragma: no cover - network path
|
||||||
|
last_error = exc
|
||||||
|
else:
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
return payload
|
||||||
|
last_error = ValueError(
|
||||||
|
f"Unexpected payload type for {base_url}{endpoint} {context}".strip()
|
||||||
|
)
|
||||||
|
|
||||||
|
if attempt < retries:
|
||||||
|
time.sleep(0.5 * attempt)
|
||||||
|
|
||||||
|
assert last_error is not None
|
||||||
|
raise last_error
|
||||||
|
|
||||||
|
|
||||||
def _coerce_match_list(payload: object) -> list[dict[str, object]]:
|
def _coerce_match_list(payload: object) -> list[dict[str, object]]:
|
||||||
if not isinstance(payload, list):
|
if not isinstance(payload, list):
|
||||||
return []
|
return []
|
||||||
@@ -317,6 +398,22 @@ def _extract_public_name(public_info: dict[str, object]) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _stringify(value: object) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
text = str(value).strip()
|
||||||
|
return text or None
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_int(value: object) -> int | None:
|
||||||
|
if value in (None, ""):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def build_arg_parser() -> argparse.ArgumentParser:
|
def build_arg_parser() -> argparse.ArgumentParser:
|
||||||
"""Create the CLI parser for manual historical ingestion runs."""
|
"""Create the CLI parser for manual historical ingestion runs."""
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
@@ -342,6 +439,17 @@ def build_arg_parser() -> argparse.ArgumentParser:
|
|||||||
type=int,
|
type=int,
|
||||||
help="override CRCON page size",
|
help="override CRCON page size",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--start-page",
|
||||||
|
type=int,
|
||||||
|
default=1,
|
||||||
|
help="resume bootstrap or refresh from a specific page number",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--detail-workers",
|
||||||
|
type=int,
|
||||||
|
help="parallel worker count for per-match detail requests",
|
||||||
|
)
|
||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
||||||
@@ -355,12 +463,16 @@ def main(argv: Iterable[str] | None = None) -> int:
|
|||||||
server_slug=args.server_slug,
|
server_slug=args.server_slug,
|
||||||
max_pages=args.max_pages,
|
max_pages=args.max_pages,
|
||||||
page_size=args.page_size,
|
page_size=args.page_size,
|
||||||
|
start_page=args.start_page,
|
||||||
|
detail_workers=args.detail_workers,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
result = run_incremental_refresh(
|
result = run_incremental_refresh(
|
||||||
server_slug=args.server_slug,
|
server_slug=args.server_slug,
|
||||||
max_pages=args.max_pages,
|
max_pages=args.max_pages,
|
||||||
page_size=args.page_size,
|
page_size=args.page_size,
|
||||||
|
start_page=args.start_page,
|
||||||
|
detail_workers=args.detail_workers,
|
||||||
)
|
)
|
||||||
|
|
||||||
print(json.dumps(result, indent=2))
|
print(json.dumps(result, indent=2))
|
||||||
|
|||||||
@@ -574,21 +574,97 @@ def list_historical_server_summaries(
|
|||||||
|
|
||||||
items: list[dict[str, object]] = []
|
items: list[dict[str, object]] = []
|
||||||
for row in summary_rows:
|
for row in summary_rows:
|
||||||
|
matches_count = int(row["matches_count"] or 0)
|
||||||
|
first_match_at = _stringify(row["first_match_at"])
|
||||||
|
last_match_at = _stringify(row["last_match_at"])
|
||||||
|
coverage_days = _calculate_coverage_days(first_match_at, last_match_at)
|
||||||
items.append(
|
items.append(
|
||||||
{
|
{
|
||||||
"server": {
|
"server": {
|
||||||
"slug": row["server_slug"],
|
"slug": row["server_slug"],
|
||||||
"name": row["server_name"],
|
"name": row["server_name"],
|
||||||
},
|
},
|
||||||
"matches_count": int(row["matches_count"] or 0),
|
"matches_count": matches_count,
|
||||||
|
"imported_matches_count": matches_count,
|
||||||
"unique_players": int(row["unique_players"] or 0),
|
"unique_players": int(row["unique_players"] or 0),
|
||||||
"total_kills": int(row["total_kills"] or 0),
|
"total_kills": int(row["total_kills"] or 0),
|
||||||
"map_count": int(row["map_count"] or 0),
|
"map_count": int(row["map_count"] or 0),
|
||||||
"top_maps": top_maps_by_server.get(str(row["server_slug"]), []),
|
"top_maps": top_maps_by_server.get(str(row["server_slug"]), []),
|
||||||
"time_range": {
|
"coverage": {
|
||||||
"start": row["first_match_at"],
|
"basis": "persisted-import",
|
||||||
"end": row["last_match_at"],
|
"status": _classify_coverage_status(matches_count, coverage_days),
|
||||||
|
"imported_matches_count": matches_count,
|
||||||
|
"first_match_at": first_match_at,
|
||||||
|
"last_match_at": last_match_at,
|
||||||
|
"coverage_days": coverage_days,
|
||||||
},
|
},
|
||||||
|
"time_range": {
|
||||||
|
"start": first_match_at,
|
||||||
|
"end": last_match_at,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def list_historical_coverage_report(
|
||||||
|
*,
|
||||||
|
server_slug: str | None = None,
|
||||||
|
db_path: Path | None = None,
|
||||||
|
) -> list[dict[str, object]]:
|
||||||
|
"""Return persisted coverage metrics used to validate historical bootstrap depth."""
|
||||||
|
resolved_path = initialize_historical_storage(db_path=db_path)
|
||||||
|
where_clause = ""
|
||||||
|
params: list[object] = []
|
||||||
|
if server_slug:
|
||||||
|
where_clause = "WHERE historical_servers.slug = ?"
|
||||||
|
params.append(server_slug)
|
||||||
|
|
||||||
|
with _connect(resolved_path) as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
f"""
|
||||||
|
SELECT
|
||||||
|
historical_servers.slug AS server_slug,
|
||||||
|
historical_servers.display_name AS server_name,
|
||||||
|
historical_servers.scoreboard_base_url AS scoreboard_base_url,
|
||||||
|
historical_servers.server_number AS server_number,
|
||||||
|
COUNT(DISTINCT historical_matches.id) AS imported_matches_count,
|
||||||
|
COUNT(DISTINCT historical_players.id) AS unique_players,
|
||||||
|
COUNT(DISTINCT historical_player_match_stats.id) AS player_stat_rows,
|
||||||
|
MIN(COALESCE(historical_matches.ended_at, historical_matches.started_at, historical_matches.created_at_source)) AS first_match_at,
|
||||||
|
MAX(COALESCE(historical_matches.ended_at, historical_matches.started_at, historical_matches.created_at_source)) AS last_match_at
|
||||||
|
FROM historical_servers
|
||||||
|
LEFT JOIN historical_matches
|
||||||
|
ON historical_matches.historical_server_id = historical_servers.id
|
||||||
|
LEFT JOIN historical_player_match_stats
|
||||||
|
ON historical_player_match_stats.historical_match_id = historical_matches.id
|
||||||
|
LEFT JOIN historical_players
|
||||||
|
ON historical_players.id = historical_player_match_stats.historical_player_id
|
||||||
|
{where_clause}
|
||||||
|
GROUP BY historical_servers.id
|
||||||
|
ORDER BY historical_servers.server_number ASC, historical_servers.slug ASC
|
||||||
|
""",
|
||||||
|
params,
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
items: list[dict[str, object]] = []
|
||||||
|
for row in rows:
|
||||||
|
first_match_at = _stringify(row["first_match_at"])
|
||||||
|
last_match_at = _stringify(row["last_match_at"])
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"server": {
|
||||||
|
"slug": row["server_slug"],
|
||||||
|
"name": row["server_name"],
|
||||||
|
"server_number": row["server_number"],
|
||||||
|
"scoreboard_base_url": row["scoreboard_base_url"],
|
||||||
|
},
|
||||||
|
"imported_matches_count": int(row["imported_matches_count"] or 0),
|
||||||
|
"unique_players": int(row["unique_players"] or 0),
|
||||||
|
"player_stat_rows": int(row["player_stat_rows"] or 0),
|
||||||
|
"first_match_at": first_match_at,
|
||||||
|
"last_match_at": last_match_at,
|
||||||
|
"coverage_days": _calculate_coverage_days(first_match_at, last_match_at),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return items
|
return items
|
||||||
@@ -1654,6 +1730,32 @@ def _parse_timestamp(value: str) -> datetime:
|
|||||||
return parsed
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def _calculate_coverage_days(
|
||||||
|
first_match_at: str | None,
|
||||||
|
last_match_at: str | None,
|
||||||
|
) -> float | None:
|
||||||
|
if not first_match_at or not last_match_at:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
delta = _parse_timestamp(last_match_at) - _parse_timestamp(first_match_at)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return round(delta.total_seconds() / 86400, 2)
|
||||||
|
|
||||||
|
|
||||||
|
def _classify_coverage_status(
|
||||||
|
matches_count: int,
|
||||||
|
coverage_days: float | None,
|
||||||
|
) -> str:
|
||||||
|
if matches_count <= 0:
|
||||||
|
return "empty"
|
||||||
|
if coverage_days is None:
|
||||||
|
return "range-unknown"
|
||||||
|
if coverage_days < DEFAULT_WEEKLY_WINDOW_DAYS:
|
||||||
|
return "under-week"
|
||||||
|
return "week-plus"
|
||||||
|
|
||||||
|
|
||||||
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:
|
||||||
|
|||||||
@@ -201,6 +201,7 @@ def build_weekly_top_kills_payload(
|
|||||||
"title": "Top kills semanales por servidor",
|
"title": "Top kills semanales por servidor",
|
||||||
"context": "historical-top-kills",
|
"context": "historical-top-kills",
|
||||||
"metric": "kills",
|
"metric": "kills",
|
||||||
|
"summary_basis": "closed-matches-last-7-days",
|
||||||
"window_days": 7,
|
"window_days": 7,
|
||||||
"window_start": result["window_start"],
|
"window_start": result["window_start"],
|
||||||
"window_end": result["window_end"],
|
"window_end": result["window_end"],
|
||||||
@@ -239,9 +240,11 @@ def build_historical_server_summary_payload(
|
|||||||
return {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"data": {
|
"data": {
|
||||||
"title": "Resumen historico por servidor",
|
"title": "Cobertura historica importada por servidor",
|
||||||
"context": "historical-server-summary",
|
"context": "historical-server-summary",
|
||||||
"source": "historical-crcon-storage",
|
"source": "historical-crcon-storage",
|
||||||
|
"summary_basis": "persisted-import",
|
||||||
|
"weekly_ranking_window_days": 7,
|
||||||
"server_slug": server_slug,
|
"server_slug": server_slug,
|
||||||
"items": items,
|
"items": items,
|
||||||
},
|
},
|
||||||
|
|||||||
89
docs/historical-coverage-report.md
Normal file
89
docs/historical-coverage-report.md
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
# Historical Coverage Report
|
||||||
|
|
||||||
|
## Validation Date
|
||||||
|
|
||||||
|
- 2026-03-20
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Estado real de la cobertura historica persistida localmente en
|
||||||
|
`backend/data/hll_vietnam_dev.sqlite3` tras ejecutar el bootstrap CRCON con el
|
||||||
|
flujo reforzado de `backend/app/historical_ingestion.py`.
|
||||||
|
|
||||||
|
## Commands Used
|
||||||
|
|
||||||
|
Desde `backend/`:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python -m app.historical_ingestion bootstrap --max-pages 3 --detail-workers 16
|
||||||
|
```
|
||||||
|
|
||||||
|
Verificacion puntual previa de idempotencia sobre la primera pagina ya
|
||||||
|
importada:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python -m app.historical_ingestion bootstrap --max-pages 1 --detail-workers 8
|
||||||
|
```
|
||||||
|
|
||||||
|
Esa reejecucion devolvio `matches_inserted: 0` y solo `matches_updated` para
|
||||||
|
los matches ya persistidos, confirmando el comportamiento idempotente en el
|
||||||
|
tramo reimportado.
|
||||||
|
|
||||||
|
## Source Depth Discovered
|
||||||
|
|
||||||
|
La propia API CRCON reporto en pagina 1:
|
||||||
|
|
||||||
|
- `comunidad-hispana-01`: `23029` matches historicos disponibles
|
||||||
|
- `comunidad-hispana-02`: `18221` matches historicos disponibles
|
||||||
|
|
||||||
|
Esto confirma que la fuente publica tiene un archivo mucho mas profundo que la
|
||||||
|
semana movil usada por la UI y que un bootstrap completo real es una operacion
|
||||||
|
larga incluso con paralelismo.
|
||||||
|
|
||||||
|
## Persisted Coverage After Bootstrap Validation
|
||||||
|
|
||||||
|
### comunidad-hispana-01
|
||||||
|
|
||||||
|
- matches importados: `150`
|
||||||
|
- jugadores unicos: `3986`
|
||||||
|
- filas de estadisticas por jugador: `12650`
|
||||||
|
- primera partida persistida: `2026-03-04T22:11:18Z`
|
||||||
|
- ultima partida persistida: `2026-03-20T21:41:18Z`
|
||||||
|
- rango cubierto: `15.98` dias
|
||||||
|
|
||||||
|
### comunidad-hispana-02
|
||||||
|
|
||||||
|
- matches importados: `150`
|
||||||
|
- jugadores unicos: `4468`
|
||||||
|
- filas de estadisticas por jugador: `12665`
|
||||||
|
- primera partida persistida: `2026-03-01T16:59:10Z`
|
||||||
|
- ultima partida persistida: `2026-03-20T21:14:21Z`
|
||||||
|
- rango cubierto: `19.18` dias
|
||||||
|
|
||||||
|
## Interpretation
|
||||||
|
|
||||||
|
- La base persistida ya supera claramente la ventana semanal en ambos
|
||||||
|
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
|
||||||
|
son lo mismo.
|
||||||
|
- El historico local sigue siendo parcial respecto al total reportado por la
|
||||||
|
fuente. Lo importado hoy es suficiente para seguir con semantica y revisiones
|
||||||
|
de UI, pero no representa aun el archivo completo disponible en CRCON.
|
||||||
|
|
||||||
|
## Source Limits Observed
|
||||||
|
|
||||||
|
- Bajo replays repetidos del mismo bootstrap, la fuente CRCON devolvio errores
|
||||||
|
`502 Bad Gateway` intermitentes en `get_public_info` y `get_map_scoreboard`.
|
||||||
|
- Con `--detail-workers 16` la carga validada fue estable para `3` paginas por
|
||||||
|
servidor. Con concurrencia mas alta se observaron payloads no validos con mas
|
||||||
|
frecuencia.
|
||||||
|
|
||||||
|
## Operational Conclusion
|
||||||
|
|
||||||
|
- El bootstrap ya es reanudable mediante `--start-page`.
|
||||||
|
- La estrategia operativa razonable para completar todo el archivo es ejecutar
|
||||||
|
varias sesiones consecutivas, registrando la ultima pagina completada y
|
||||||
|
continuando desde ahi si el origen falla o si la sesion debe cortarse.
|
||||||
|
- Mientras no se complete todo el archivo, cualquier UI o API debe mostrar la
|
||||||
|
cobertura importada como cobertura real disponible y no como historico total
|
||||||
|
del servidor.
|
||||||
@@ -47,6 +47,13 @@
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.historical-panel__note {
|
||||||
|
max-width: 58ch;
|
||||||
|
margin: 10px 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
.historical-selector {
|
.historical-selector {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
@@ -12,9 +12,11 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
);
|
);
|
||||||
const summaryNode = document.getElementById("historical-summary");
|
const summaryNode = document.getElementById("historical-summary");
|
||||||
const rangeNode = document.getElementById("historical-range");
|
const rangeNode = document.getElementById("historical-range");
|
||||||
|
const summaryNoteNode = document.getElementById("historical-summary-note");
|
||||||
const weeklyStateNode = document.getElementById("weekly-top-kills-state");
|
const weeklyStateNode = document.getElementById("weekly-top-kills-state");
|
||||||
const weeklyTableNode = document.getElementById("weekly-top-kills-table");
|
const weeklyTableNode = document.getElementById("weekly-top-kills-table");
|
||||||
const weeklyBodyNode = document.getElementById("weekly-top-kills-body");
|
const weeklyBodyNode = document.getElementById("weekly-top-kills-body");
|
||||||
|
const weeklyWindowNoteNode = document.getElementById("weekly-window-note");
|
||||||
const recentStateNode = document.getElementById("recent-matches-state");
|
const recentStateNode = document.getElementById("recent-matches-state");
|
||||||
const recentListNode = document.getElementById("recent-matches-list");
|
const recentListNode = document.getElementById("recent-matches-list");
|
||||||
|
|
||||||
@@ -24,7 +26,10 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
const refreshHistoricalView = async () => {
|
const refreshHistoricalView = async () => {
|
||||||
syncActiveButtons(selectorButtons, activeServerSlug);
|
syncActiveButtons(selectorButtons, activeServerSlug);
|
||||||
setRangeBadge(rangeNode, "Cargando rango temporal", false);
|
setRangeBadge(rangeNode, "Cargando rango temporal", false);
|
||||||
|
summaryNoteNode.textContent =
|
||||||
|
"Este bloque resume solo la cobertura ya importada en la base local.";
|
||||||
renderSummaryLoading(summaryNode);
|
renderSummaryLoading(summaryNode);
|
||||||
|
weeklyWindowNoteNode.textContent = "Cargando ventana semanal...";
|
||||||
setState(weeklyStateNode, "Cargando ranking semanal...");
|
setState(weeklyStateNode, "Cargando ranking semanal...");
|
||||||
setState(recentStateNode, "Cargando partidas recientes...");
|
setState(recentStateNode, "Cargando partidas recientes...");
|
||||||
weeklyTableNode.hidden = true;
|
weeklyTableNode.hidden = true;
|
||||||
@@ -43,12 +48,13 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
hydrateSummary(summaryResult, summaryNode, rangeNode);
|
hydrateSummary(summaryResult, summaryNode, rangeNode, summaryNoteNode);
|
||||||
hydrateWeeklyTopKills(
|
hydrateWeeklyTopKills(
|
||||||
topKillsResult,
|
topKillsResult,
|
||||||
weeklyStateNode,
|
weeklyStateNode,
|
||||||
weeklyTableNode,
|
weeklyTableNode,
|
||||||
weeklyBodyNode,
|
weeklyBodyNode,
|
||||||
|
weeklyWindowNoteNode,
|
||||||
);
|
);
|
||||||
hydrateRecentMatches(recentMatchesResult, recentStateNode, recentListNode);
|
hydrateRecentMatches(recentMatchesResult, recentStateNode, recentListNode);
|
||||||
};
|
};
|
||||||
@@ -70,33 +76,52 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
void refreshHistoricalView();
|
void refreshHistoricalView();
|
||||||
});
|
});
|
||||||
|
|
||||||
function hydrateSummary(result, summaryNode, rangeNode) {
|
function hydrateSummary(result, summaryNode, rangeNode, noteNode) {
|
||||||
if (result.status !== "fulfilled") {
|
if (result.status !== "fulfilled") {
|
||||||
renderSummaryError(summaryNode);
|
renderSummaryError(summaryNode);
|
||||||
setRangeBadge(rangeNode, "Resumen historico no disponible", false);
|
setRangeBadge(rangeNode, "Resumen historico no disponible", false);
|
||||||
|
noteNode.textContent =
|
||||||
|
"No se pudo leer la cobertura importada persistida para este servidor.";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const items = result.value?.data?.items;
|
const payload = result.value?.data;
|
||||||
|
const items = payload?.items;
|
||||||
if (!Array.isArray(items) || items.length === 0) {
|
if (!Array.isArray(items) || items.length === 0) {
|
||||||
renderSummaryEmpty(summaryNode);
|
renderSummaryEmpty(summaryNode);
|
||||||
setRangeBadge(rangeNode, "Sin cobertura historica", false);
|
setRangeBadge(rangeNode, "Sin cobertura historica", false);
|
||||||
|
noteNode.textContent =
|
||||||
|
"Todavia no existe cobertura importada suficiente en la base local.";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const summary = items[0];
|
const summary = items[0];
|
||||||
|
const coverage = summary.coverage || {};
|
||||||
const timeRange = summary.time_range || {};
|
const timeRange = summary.time_range || {};
|
||||||
const rangeLabel = buildRangeLabel(timeRange.start, timeRange.end);
|
const rangeLabel = buildCoverageBadgeLabel(coverage, timeRange);
|
||||||
setRangeBadge(
|
setRangeBadge(
|
||||||
rangeNode,
|
rangeNode,
|
||||||
rangeLabel || "Cobertura parcial disponible",
|
rangeLabel || "Cobertura importada parcial",
|
||||||
Boolean(rangeLabel),
|
coverage.status === "week-plus",
|
||||||
|
);
|
||||||
|
noteNode.textContent = buildSummaryNote(
|
||||||
|
payload?.summary_basis,
|
||||||
|
payload?.weekly_ranking_window_days,
|
||||||
|
coverage,
|
||||||
);
|
);
|
||||||
summaryNode.innerHTML = [
|
summaryNode.innerHTML = [
|
||||||
renderSummaryCard("Servidor", summary.server?.name || "Servidor no disponible"),
|
renderSummaryCard("Servidor", summary.server?.name || "Servidor no disponible"),
|
||||||
renderSummaryCard("Partidas", formatNumber(summary.matches_count)),
|
renderSummaryCard(
|
||||||
|
"Partidas importadas",
|
||||||
|
formatNumber(summary.imported_matches_count ?? summary.matches_count),
|
||||||
|
),
|
||||||
renderSummaryCard("Jugadores unicos", formatNumber(summary.unique_players)),
|
renderSummaryCard("Jugadores unicos", formatNumber(summary.unique_players)),
|
||||||
renderSummaryCard("Kills agregadas", formatNumber(summary.total_kills)),
|
renderSummaryCard(
|
||||||
|
"Cobertura importada",
|
||||||
|
formatCoverageDays(coverage.coverage_days),
|
||||||
|
),
|
||||||
|
renderSummaryCard("Primera partida", formatTimestamp(coverage.first_match_at)),
|
||||||
|
renderSummaryCard("Ultima partida", formatTimestamp(coverage.last_match_at)),
|
||||||
renderSummaryCard(
|
renderSummaryCard(
|
||||||
"Mapas dominantes",
|
"Mapas dominantes",
|
||||||
formatTopMaps(summary.top_maps),
|
formatTopMaps(summary.top_maps),
|
||||||
@@ -109,14 +134,19 @@ function hydrateWeeklyTopKills(
|
|||||||
stateNode,
|
stateNode,
|
||||||
tableNode,
|
tableNode,
|
||||||
bodyNode,
|
bodyNode,
|
||||||
|
noteNode,
|
||||||
) {
|
) {
|
||||||
if (result.status !== "fulfilled") {
|
if (result.status !== "fulfilled") {
|
||||||
|
noteNode.textContent =
|
||||||
|
"El ranking usa solo partidas cerradas dentro de la ultima ventana semanal.";
|
||||||
setState(stateNode, "No se pudo cargar el ranking semanal.", true);
|
setState(stateNode, "No se pudo cargar el ranking semanal.", true);
|
||||||
tableNode.hidden = true;
|
tableNode.hidden = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const items = result.value?.data?.items;
|
const payload = result.value?.data;
|
||||||
|
noteNode.textContent = buildWeeklyWindowNote(payload);
|
||||||
|
const items = payload?.items;
|
||||||
if (!Array.isArray(items) || items.length === 0) {
|
if (!Array.isArray(items) || items.length === 0) {
|
||||||
setState(
|
setState(
|
||||||
stateNode,
|
stateNode,
|
||||||
@@ -251,6 +281,41 @@ function buildRangeLabel(start, end) {
|
|||||||
return `${formatTimestamp(start)} a ${formatTimestamp(end)}`;
|
return `${formatTimestamp(start)} a ${formatTimestamp(end)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildCoverageBadgeLabel(coverage, timeRange) {
|
||||||
|
const coverageDays = formatCoverageDays(coverage?.coverage_days);
|
||||||
|
const rangeLabel = buildRangeLabel(
|
||||||
|
coverage?.first_match_at || timeRange?.start,
|
||||||
|
coverage?.last_match_at || timeRange?.end,
|
||||||
|
);
|
||||||
|
if (coverageDays !== "Cobertura no disponible" && rangeLabel) {
|
||||||
|
return `${coverageDays} importados`;
|
||||||
|
}
|
||||||
|
return rangeLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSummaryNote(summaryBasis, weeklyWindowDays, coverage) {
|
||||||
|
const basisLabel =
|
||||||
|
summaryBasis === "persisted-import"
|
||||||
|
? "la cobertura ya importada en la base local"
|
||||||
|
: "el historico persistido disponible";
|
||||||
|
const weeklyWindowLabel = Number.isFinite(Number(weeklyWindowDays))
|
||||||
|
? `${weeklyWindowDays} dias`
|
||||||
|
: "la ultima semana";
|
||||||
|
const status = coverage?.status;
|
||||||
|
if (status === "under-week") {
|
||||||
|
return `Este bloque resume ${basisLabel}. Ahora mismo esa cobertura todavia no alcanza ${weeklyWindowLabel}.`;
|
||||||
|
}
|
||||||
|
return `Este bloque resume ${basisLabel}. El ranking semanal de abajo usa solo partidas cerradas de los ultimos ${weeklyWindowLabel}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWeeklyWindowNote(payload) {
|
||||||
|
const start = formatTimestamp(payload?.window_start);
|
||||||
|
const end = formatTimestamp(payload?.window_end);
|
||||||
|
const windowDays = Number(payload?.window_days);
|
||||||
|
const daysLabel = Number.isFinite(windowDays) ? `${windowDays} dias` : "7 dias";
|
||||||
|
return `Ranking calculado solo con partidas cerradas dentro de la ventana movil de ${daysLabel}: ${start} a ${end}.`;
|
||||||
|
}
|
||||||
|
|
||||||
function formatTopMaps(topMaps) {
|
function formatTopMaps(topMaps) {
|
||||||
if (!Array.isArray(topMaps) || topMaps.length === 0) {
|
if (!Array.isArray(topMaps) || topMaps.length === 0) {
|
||||||
return "Sin mapas dominantes";
|
return "Sin mapas dominantes";
|
||||||
@@ -261,6 +326,17 @@ function formatTopMaps(topMaps) {
|
|||||||
.join(" / ");
|
.join(" / ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatCoverageDays(value) {
|
||||||
|
const parsedValue = Number(value);
|
||||||
|
if (!Number.isFinite(parsedValue) || parsedValue <= 0) {
|
||||||
|
return "Cobertura no disponible";
|
||||||
|
}
|
||||||
|
return `${new Intl.NumberFormat("es-ES", {
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
minimumFractionDigits: parsedValue < 10 ? 1 : 0,
|
||||||
|
}).format(parsedValue)} dias`;
|
||||||
|
}
|
||||||
|
|
||||||
function formatMatchResult(result) {
|
function formatMatchResult(result) {
|
||||||
const winner = result?.winner;
|
const winner = result?.winner;
|
||||||
if (winner === "allies") {
|
if (winner === "allies") {
|
||||||
|
|||||||
@@ -29,8 +29,8 @@
|
|||||||
<span class="hero__title-accent">Comunidad Hispana</span>
|
<span class="hero__title-accent">Comunidad Hispana</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p class="hero__text historical-hero__text">
|
<p class="hero__text historical-hero__text">
|
||||||
Consulta el ranking semanal y las partidas recientes usando solo
|
Consulta cobertura importada, ranking semanal y cierres
|
||||||
la capa historica interna del proyecto.
|
recientes usando solo la capa historica interna del proyecto.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="historical-selector" aria-label="Seleccion de servidor">
|
<div class="historical-selector" aria-label="Seleccion de servidor">
|
||||||
@@ -55,16 +55,19 @@
|
|||||||
|
|
||||||
<main class="content historical-content">
|
<main class="content historical-content">
|
||||||
<section class="panel historical-panel" aria-labelledby="historical-summary-title">
|
<section class="panel historical-panel" aria-labelledby="historical-summary-title">
|
||||||
<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">Cobertura</p>
|
<p class="eyebrow eyebrow--section">Cobertura importada</p>
|
||||||
<h2 id="historical-summary-title">Resumen del servidor</h2>
|
<h2 id="historical-summary-title">Resumen del historico persistido</h2>
|
||||||
</div>
|
<p class="historical-panel__note" id="historical-summary-note">
|
||||||
|
Este bloque resume solo la cobertura ya importada en la base local.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<p class="status-chip status-chip--fallback" id="historical-range">
|
<p class="status-chip status-chip--fallback" id="historical-range">
|
||||||
Cargando rango temporal
|
Cargando rango temporal
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="historical-summary-grid" id="historical-summary">
|
<div class="historical-summary-grid" id="historical-summary">
|
||||||
<article class="historical-stat-card">
|
<article class="historical-stat-card">
|
||||||
<p>Estado</p>
|
<p>Estado</p>
|
||||||
@@ -76,12 +79,15 @@
|
|||||||
|
|
||||||
<section class="panel historical-panel" aria-labelledby="weekly-ranking-title">
|
<section class="panel historical-panel" aria-labelledby="weekly-ranking-title">
|
||||||
<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">Ranking semanal</p>
|
||||||
<h2 id="weekly-ranking-title">Top kills de los ultimos 7 dias</h2>
|
<h2 id="weekly-ranking-title">Top kills de los ultimos 7 dias</h2>
|
||||||
|
<p class="historical-panel__note" id="weekly-window-note">
|
||||||
|
Cargando ventana semanal...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<p class="historical-state" id="weekly-top-kills-state" aria-live="polite">
|
<p class="historical-state" id="weekly-top-kills-state" aria-live="polite">
|
||||||
Cargando ranking semanal...
|
Cargando ranking semanal...
|
||||||
</p>
|
</p>
|
||||||
@@ -104,12 +110,15 @@
|
|||||||
|
|
||||||
<section class="panel historical-panel" aria-labelledby="recent-matches-title">
|
<section class="panel historical-panel" aria-labelledby="recent-matches-title">
|
||||||
<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">Partidas recientes</p>
|
<p class="eyebrow eyebrow--section">Partidas recientes</p>
|
||||||
<h2 id="recent-matches-title">Ultimos cierres registrados</h2>
|
<h2 id="recent-matches-title">Ultimos cierres registrados</h2>
|
||||||
|
<p class="historical-panel__note">
|
||||||
|
Lista de cierres ya importados para el servidor activo.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<p class="historical-state" id="recent-matches-state" aria-live="polite">
|
<p class="historical-state" id="recent-matches-state" aria-live="polite">
|
||||||
Cargando partidas recientes...
|
Cargando partidas recientes...
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user