Process historical snapshot tasks 047-051
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -15,3 +15,5 @@ ai/worker.lock
|
|||||||
# Local backend runtime data
|
# Local backend runtime data
|
||||||
backend/data/*.sqlite3
|
backend/data/*.sqlite3
|
||||||
!backend/data/.gitkeep
|
!backend/data/.gitkeep
|
||||||
|
backend/data/snapshots/**
|
||||||
|
!backend/data/snapshots/.gitkeep
|
||||||
|
|||||||
86
ai/tasks/done/TASK-047-fix-server-02-snapshot-generation.md
Normal file
86
ai/tasks/done/TASK-047-fix-server-02-snapshot-generation.md
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
# TASK-047-fix-server-02-snapshot-generation
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Diagnosticar y corregir por qué `comunidad-hispana-02` sigue apareciendo sin snapshots válidos en la UI histórica, asegurando que resumen, tops y partidas recientes se generen y queden disponibles igual que en `comunidad-hispana-01`.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
La capa histórica ya genera snapshots funcionales para al menos uno de los servidores, pero en la práctica `comunidad-hispana-02` sigue mostrando estados vacíos o “sin snapshot” en la interfaz. El problema no parece ser la ausencia de soporte de servidor en el código, sino un fallo de generación, persistencia, selección o consumo de snapshots. Antes de seguir ampliando la plataforma histórica, hay que dejar corregida la paridad entre ambos servidores actuales.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
1. Revisar la configuración histórica actual de `comunidad-hispana-01` y `comunidad-hispana-02`.
|
||||||
|
2. Revisar la ruta completa de snapshots para ambos servidores:
|
||||||
|
- histórico bruto
|
||||||
|
- generación de snapshots
|
||||||
|
- persistencia de snapshots
|
||||||
|
- lectura de snapshots
|
||||||
|
- consumo frontend
|
||||||
|
3. Identificar por qué `comunidad-hispana-02` no devuelve snapshots válidos aunque exista histórico bruto o soporte parcial.
|
||||||
|
4. Corregir la causa raíz, ya sea en:
|
||||||
|
- mapeo de servidor
|
||||||
|
- generación
|
||||||
|
- persistencia
|
||||||
|
- recuperación
|
||||||
|
- cache frontend
|
||||||
|
5. Asegurar que para `comunidad-hispana-02` queden disponibles snapshots de:
|
||||||
|
- resumen
|
||||||
|
- leaderboard semanal por métrica
|
||||||
|
- partidas recientes
|
||||||
|
6. Verificar que ambos servidores actuales se comportan de forma equivalente.
|
||||||
|
7. Documentar brevemente la causa detectada y la corrección.
|
||||||
|
8. No añadir todavía el servidor #03 en esta task.
|
||||||
|
9. Al completar la implementación:
|
||||||
|
- dejar el repositorio consistente
|
||||||
|
- hacer commit
|
||||||
|
- hacer push al remoto si el entorno lo permite
|
||||||
|
|
||||||
|
## Files to Read First
|
||||||
|
- AGENTS.md
|
||||||
|
- backend/README.md
|
||||||
|
- backend/app/config.py
|
||||||
|
- backend/app/historical_ingestion.py
|
||||||
|
- backend/app/historical_storage.py
|
||||||
|
- backend/app/historical_snapshot_storage.py
|
||||||
|
- backend/app/historical_snapshots.py
|
||||||
|
- backend/app/payloads.py
|
||||||
|
- backend/app/routes.py
|
||||||
|
- frontend/historico.html
|
||||||
|
- frontend/assets/js/historico.js
|
||||||
|
- frontend/assets/css/historico.css
|
||||||
|
- docs/historical-coverage-report.md
|
||||||
|
- docs/historical-data-quality-notes.md
|
||||||
|
|
||||||
|
## Expected Files to Modify
|
||||||
|
- backend/app/historical_ingestion.py
|
||||||
|
- backend/app/historical_storage.py
|
||||||
|
- backend/app/historical_snapshot_storage.py
|
||||||
|
- backend/app/historical_snapshots.py
|
||||||
|
- backend/app/payloads.py
|
||||||
|
- backend/app/routes.py
|
||||||
|
- frontend/assets/js/historico.js
|
||||||
|
- backend/README.md
|
||||||
|
- opcionalmente documentación técnica adicional si ayuda a dejar trazabilidad
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
- No usar A2S para esta corrección.
|
||||||
|
- No crear páginas nuevas.
|
||||||
|
- No romper el flujo actual de `comunidad-hispana-01`.
|
||||||
|
- No hacer cambios destructivos.
|
||||||
|
- Mantener el trabajo centrado en que `comunidad-hispana-02` tenga snapshots funcionales.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
- `comunidad-hispana-02` deja de mostrar estados vacíos si existe histórico suficiente.
|
||||||
|
- Resumen, tops y partidas recientes funcionan también para `comunidad-hispana-02`.
|
||||||
|
- No se rompe `comunidad-hispana-01`.
|
||||||
|
- Los cambios quedan committeados y se hace push si el entorno lo permite.
|
||||||
|
|
||||||
|
## Change Budget
|
||||||
|
- Preferir menos de 7 archivos modificados o creados.
|
||||||
|
- Preferir menos de 260 líneas cambiadas.
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
- Causa detectada: `comunidad-hispana-02` ya tenía histórico bruto persistido, pero la API de snapshots devolvía `found: false` cuando faltaba la fila precalculada en `historical_precomputed_snapshots`, sin recomponerla desde `historical_*`.
|
||||||
|
- Corrección aplicada: los builders de payload histórico ahora regeneran automáticamente el lote de snapshots del servidor solicitado cuando falta una fila precalculada y luego reintentan la lectura.
|
||||||
|
- Validación realizada:
|
||||||
|
- simulación sobre una copia del SQLite eliminando las filas de snapshot de `comunidad-hispana-02`
|
||||||
|
- la API recompuso `6` snapshots del servidor y devolvió `found: true` para resumen, ranking semanal y partidas recientes
|
||||||
|
- verificado también el SQLite real: `comunidad-hispana-02` queda con `6` snapshots persistidos
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# TASK-048-add-server-03-historical-source-and-ingestion
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Añadir el tercer servidor histórico de la comunidad (`https://scoreboard.comunidadhll.es:3443/`) a la capa histórica del proyecto, dejándolo preparado para ingesta, snapshots y consumo posterior por la UI propia.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
Ahora mismo el proyecto trabaja con dos servidores históricos de comunidad, pero existe un tercer servidor real:
|
||||||
|
- `https://scoreboard.comunidadhll.es:3443/`
|
||||||
|
|
||||||
|
El sistema debe poder tratarlo como una tercera fuente histórica formal, no como un caso manual o externo, manteniendo la misma arquitectura de ingestión, persistencia y snapshots propia del proyecto.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
1. Revisar cómo están definidos hoy los servidores históricos actuales.
|
||||||
|
2. Añadir la configuración y el mapeo del tercer servidor con una identidad estable, por ejemplo:
|
||||||
|
- `comunidad-hispana-03`
|
||||||
|
3. Asegurar que la capa de ingestión histórica puede consultar la fuente CRCON JSON del puerto `3443`.
|
||||||
|
4. Preparar la persistencia histórica para ese servidor:
|
||||||
|
- matches
|
||||||
|
- players
|
||||||
|
- stats por match
|
||||||
|
- checkpoints/backfill
|
||||||
|
5. Integrar el tercer servidor en la capa de snapshots:
|
||||||
|
- resumen
|
||||||
|
- rankings semanales
|
||||||
|
- partidas recientes
|
||||||
|
6. Ajustar documentación y configuración operativa para reflejar que ya existen tres servidores históricos.
|
||||||
|
7. No exponer todavía UI nueva si no es imprescindible en esta task.
|
||||||
|
8. Al completar la implementación:
|
||||||
|
- dejar el repositorio consistente
|
||||||
|
- hacer commit
|
||||||
|
- hacer push al remoto si el entorno lo permite
|
||||||
|
|
||||||
|
## Files to Read First
|
||||||
|
- AGENTS.md
|
||||||
|
- backend/README.md
|
||||||
|
- backend/app/config.py
|
||||||
|
- backend/app/historical_ingestion.py
|
||||||
|
- backend/app/historical_storage.py
|
||||||
|
- backend/app/historical_snapshot_storage.py
|
||||||
|
- backend/app/historical_snapshots.py
|
||||||
|
- backend/app/payloads.py
|
||||||
|
- backend/app/routes.py
|
||||||
|
- docs/historical-crcon-source-discovery.md
|
||||||
|
- docs/historical-domain-model.md
|
||||||
|
|
||||||
|
## Expected Files to Modify
|
||||||
|
- backend/app/config.py
|
||||||
|
- backend/app/historical_ingestion.py
|
||||||
|
- backend/app/historical_storage.py
|
||||||
|
- backend/app/historical_snapshot_storage.py
|
||||||
|
- backend/app/historical_snapshots.py
|
||||||
|
- backend/app/payloads.py
|
||||||
|
- backend/app/routes.py
|
||||||
|
- backend/README.md
|
||||||
|
- opcionalmente documentación técnica adicional
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
- No usar A2S para el histórico del servidor #03.
|
||||||
|
- No crear páginas externas o dependientes de la comunidad.
|
||||||
|
- No romper los servidores #01 y #02.
|
||||||
|
- No hacer cambios destructivos.
|
||||||
|
- Mantener el trabajo centrado en registrar correctamente el servidor #03 en la plataforma histórica.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
- El servidor #03 queda definido con identidad estable en backend.
|
||||||
|
- La ingesta histórica puede trabajar con su fuente.
|
||||||
|
- La capa de snapshots queda preparada para ese servidor.
|
||||||
|
- La documentación backend refleja el nuevo servidor.
|
||||||
|
- Los cambios quedan committeados y se hace push si el entorno lo permite.
|
||||||
|
|
||||||
|
## Change Budget
|
||||||
|
- Preferir menos de 7 archivos modificados o creados.
|
||||||
|
- Preferir menos de 260 líneas cambiadas.
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
- Se añadió `comunidad-hispana-03` como tercera fuente histórica estable en el seed declarativo de `historical_servers`, con `scoreboard_base_url` `https://scoreboard.comunidadhll.es:3443` y `server_number` `3`.
|
||||||
|
- La validación local de `list_historical_servers()` confirma que el backend ya registra `3` servidores históricos.
|
||||||
|
- La validación local de `build_historical_server_snapshots(server_key='comunidad-hispana-03')` devuelve el lote esperado de `6` snapshots, dejando preparada la capa para ingesta, persistencia y consumo posterior aunque la UI aún no se haya ampliado en esta task.
|
||||||
|
- Se actualizó la documentación backend y de dominio para reflejar la nueva fuente histórica.
|
||||||
81
ai/tasks/done/TASK-049-global-all-servers-historical-tops.md
Normal file
81
ai/tasks/done/TASK-049-global-all-servers-historical-tops.md
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
# TASK-049-global-all-servers-historical-tops
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Añadir soporte para rankings históricos globales agregando los tres servidores de la comunidad, de forma que el proyecto pueda mostrar tops totales además de tops por servidor.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
Actualmente los rankings históricos están organizados por servidor. El siguiente paso es poder consultar tops globales, por ejemplo:
|
||||||
|
- top kills total
|
||||||
|
- top muertes total
|
||||||
|
- top soporte total
|
||||||
|
- top partidas con más de 100 kills total
|
||||||
|
|
||||||
|
La forma más limpia es tratar este agregado como una entidad lógica propia, por ejemplo `all-servers`, compatible con la capa de snapshots y con la futura UI.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
1. Revisar la estructura actual de rankings históricos por servidor.
|
||||||
|
2. Diseñar una estrategia clara para soportar rankings totales agregados, preferiblemente mediante una clave lógica como:
|
||||||
|
- `all-servers`
|
||||||
|
3. Implementar el agregado global para:
|
||||||
|
- top kills
|
||||||
|
- top muertes
|
||||||
|
- top soporte
|
||||||
|
- top partidas con más de 100 kills
|
||||||
|
4. Asegurar que estos rankings globales:
|
||||||
|
- no mezclan mal identidades de jugador
|
||||||
|
- respetan la ventana semanal o política temporal definida
|
||||||
|
- son compatibles con snapshots
|
||||||
|
5. Integrar el agregado global en resumen y metadatos cuando aplique.
|
||||||
|
6. Documentar la semántica de `all-servers`.
|
||||||
|
7. No crear todavía una UI nueva compleja en esta task si no es estrictamente necesario.
|
||||||
|
8. 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/historical_storage.py
|
||||||
|
- backend/app/historical_snapshot_storage.py
|
||||||
|
- backend/app/historical_snapshots.py
|
||||||
|
- backend/app/payloads.py
|
||||||
|
- backend/app/routes.py
|
||||||
|
- docs/historical-domain-model.md
|
||||||
|
- docs/historical-data-quality-notes.md
|
||||||
|
|
||||||
|
## Expected Files to Modify
|
||||||
|
- backend/app/historical_storage.py
|
||||||
|
- backend/app/historical_snapshot_storage.py
|
||||||
|
- backend/app/historical_snapshots.py
|
||||||
|
- backend/app/payloads.py
|
||||||
|
- backend/app/routes.py
|
||||||
|
- backend/README.md
|
||||||
|
- opcionalmente documentación técnica adicional
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
- No romper rankings por servidor existentes.
|
||||||
|
- No usar A2S para rankings globales históricos.
|
||||||
|
- No hacer cambios destructivos.
|
||||||
|
- Mantener el trabajo centrado en el agregado global de tops y snapshots.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
- Existe soporte histórico para tops globales `all-servers`.
|
||||||
|
- Los rankings globales funcionan para las métricas ya soportadas.
|
||||||
|
- Los rankings por servidor siguen funcionando.
|
||||||
|
- La documentación backend refleja la nueva capacidad.
|
||||||
|
- Los cambios quedan committeados y se hace push si el entorno lo permite.
|
||||||
|
|
||||||
|
## Change Budget
|
||||||
|
- Preferir menos de 6 archivos modificados o creados.
|
||||||
|
- Preferir menos de 240 líneas cambiadas.
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
- Se añadió la clave lógica `all-servers` para la capa histórica agregada sin crear una fila física adicional en `historical_servers`.
|
||||||
|
- `list_weekly_leaderboard()` y los payloads/snapshots asociados ya devuelven rankings globales para `kills`, `deaths`, `support` y `matches_over_100_kills`.
|
||||||
|
- `list_historical_server_summaries(server_slug='all-servers')` devuelve un resumen agregado con identidad estable `all-servers`.
|
||||||
|
- `list_snapshot_server_keys()` ya incluye `all-servers`, por lo que la capa de snapshots queda compatible con el agregado global.
|
||||||
|
- Validación local:
|
||||||
|
- `list_weekly_leaderboard(server_id='all-servers', metric='kills', limit=5)` devolvió resultados agregados con servidor lógico `all-servers`
|
||||||
|
- `build_historical_server_snapshots(server_key='all-servers')` devolvió `6` snapshots
|
||||||
|
- `build_weekly_leaderboard_snapshot_payload(server_id='all-servers', metric='kills')` devolvió `found: true`
|
||||||
86
ai/tasks/done/TASK-050-file-based-historical-snapshots.md
Normal file
86
ai/tasks/done/TASK-050-file-based-historical-snapshots.md
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
# TASK-050-file-based-historical-snapshots
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Migrar la capa de snapshots históricos orientados a UI desde almacenamiento SQLite a archivos JSON independientes en disco, manteniendo SQLite para el histórico bruto y dejando los snapshots como artefactos rápidos, inspeccionables y fáciles de servir.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
El proyecto ya usa snapshots precalculados, pero actualmente se almacenan en SQLite. Se quiere cambiar el enfoque para que cada snapshot UI exista como archivo JSON independiente, actualizado periódicamente por backend y consumido a través de la API propia. La razón es reducir complejidad percibida, facilitar inspección/depuración y reforzar una carga rápida y estable del frontend.
|
||||||
|
|
||||||
|
El histórico bruto persistido en SQLite debe mantenerse. Lo que cambia en esta task es solo la capa de snapshots precalculados de UI.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
1. Revisar la capa actual de snapshots precalculados en SQLite.
|
||||||
|
2. Diseñar una nueva estructura de archivos para snapshots en disco, por ejemplo:
|
||||||
|
- `backend/data/snapshots/comunidad-hispana-01/server-summary.json`
|
||||||
|
- `backend/data/snapshots/comunidad-hispana-01/weekly-kills.json`
|
||||||
|
- `backend/data/snapshots/comunidad-hispana-01/weekly-deaths.json`
|
||||||
|
- `backend/data/snapshots/comunidad-hispana-01/weekly-support.json`
|
||||||
|
- `backend/data/snapshots/comunidad-hispana-01/weekly-matches-over-100-kills.json`
|
||||||
|
- `backend/data/snapshots/comunidad-hispana-01/recent-matches.json`
|
||||||
|
- y equivalentes para `comunidad-hispana-02`, `comunidad-hispana-03` y `all-servers`
|
||||||
|
3. Implementar almacenamiento y lectura de snapshots en JSON.
|
||||||
|
4. Migrar la generación de snapshots para que escriba estos archivos.
|
||||||
|
5. Mantener metadatos útiles dentro de cada snapshot, como:
|
||||||
|
- generated_at
|
||||||
|
- source_range_start
|
||||||
|
- source_range_end
|
||||||
|
- freshness / is_stale
|
||||||
|
- found
|
||||||
|
- política semanal usada si aplica
|
||||||
|
6. Mantener SQLite para histórico bruto y no mezclar ambas capas.
|
||||||
|
7. Documentar claramente la nueva arquitectura:
|
||||||
|
- histórico bruto en SQLite
|
||||||
|
- snapshots UI en archivos JSON
|
||||||
|
8. No romper todavía la UI actual.
|
||||||
|
9. Al completar la implementación:
|
||||||
|
- dejar el repositorio consistente
|
||||||
|
- hacer commit
|
||||||
|
- hacer push al remoto si el entorno lo permite
|
||||||
|
|
||||||
|
## Files to Read First
|
||||||
|
- AGENTS.md
|
||||||
|
- backend/README.md
|
||||||
|
- backend/app/historical_storage.py
|
||||||
|
- backend/app/historical_snapshot_storage.py
|
||||||
|
- backend/app/historical_snapshots.py
|
||||||
|
- backend/app/historical_runner.py
|
||||||
|
- backend/app/payloads.py
|
||||||
|
- backend/app/routes.py
|
||||||
|
- docs/historical-domain-model.md
|
||||||
|
|
||||||
|
## Expected Files to Modify
|
||||||
|
- backend/README.md
|
||||||
|
- backend/app/historical_snapshot_storage.py
|
||||||
|
- backend/app/historical_snapshots.py
|
||||||
|
- backend/app/historical_runner.py
|
||||||
|
- backend/app/payloads.py
|
||||||
|
- backend/app/routes.py
|
||||||
|
- opcionalmente nuevos módulos auxiliares, por ejemplo:
|
||||||
|
- backend/app/historical_file_snapshots.py
|
||||||
|
- opcionalmente documentación técnica adicional
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
- No eliminar SQLite del histórico bruto.
|
||||||
|
- No romper la API histórica existente salvo para adaptarla a la nueva fuente de snapshots.
|
||||||
|
- No hacer cambios destructivos.
|
||||||
|
- Mantener el trabajo centrado en pasar snapshots UI a JSON en disco.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
- Los snapshots UI se generan y guardan como archivos JSON independientes en disco.
|
||||||
|
- El histórico bruto sigue persistiendo en SQLite.
|
||||||
|
- La API histórica puede servir esos snapshots correctamente.
|
||||||
|
- La estructura es inspeccionable y mantenible.
|
||||||
|
- Los cambios quedan committeados y se hace push si el entorno lo permite.
|
||||||
|
|
||||||
|
## Change Budget
|
||||||
|
- Preferir menos de 7 archivos modificados o creados.
|
||||||
|
- Preferir menos de 260 líneas cambiadas.
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
- La persistencia de snapshots históricos orientados a UI se migró de SQLite a archivos JSON bajo `backend/data/snapshots/<server_key>/`.
|
||||||
|
- `historical_snapshot_storage.py` conserva la misma interfaz pública (`persist_*`, `get_*`, `list_*`) pero ahora escribe y lee archivos como `server-summary.json`, `weekly-kills.json` y `recent-matches.json`.
|
||||||
|
- SQLite se mantiene exclusivamente para el histórico bruto (`historical_*`) y ya no es la fuente de lectura de snapshots UI.
|
||||||
|
- Validación local:
|
||||||
|
- `generate_and_persist_historical_snapshots(server_key='comunidad-hispana-03')` y `generate_and_persist_historical_snapshots(server_key='all-servers')` escribieron su lote de `6` archivos JSON cada uno
|
||||||
|
- verificado el árbol `backend/data/snapshots/` con `24` archivos JSON esperados para `comunidad-hispana-01`, `comunidad-hispana-02`, `comunidad-hispana-03` y `all-servers`
|
||||||
|
- los payloads `build_historical_server_summary_snapshot_payload(server_slug='comunidad-hispana-02')`, `build_weekly_leaderboard_snapshot_payload(server_id='all-servers', metric='kills')` y `build_recent_historical_matches_snapshot_payload(server_slug='comunidad-hispana-03')` devolvieron `found: true`
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# TASK-051-historical-ui-fast-snapshot-consumption
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Ajustar la UI histórica para consumir de forma rápida y estable los snapshots precalculados de resumen, rankings y partidas recientes, incluyendo soporte para el tercer servidor y para tops globales.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
Una vez exista una capa de snapshots en archivos JSON y estén resueltos los servidores #02 y #03, la UI histórica debe consumir esa capa sin esperas innecesarias, permitiendo cambiar de servidor o pestaña sin sensaciones de bloqueo. Además, debe soportar un selector ampliado con:
|
||||||
|
- Comunidad Hispana #01
|
||||||
|
- Comunidad Hispana #02
|
||||||
|
- Comunidad Hispana #03
|
||||||
|
- Totales / Todos
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
1. Revisar la UI histórica actual y su consumo de snapshots.
|
||||||
|
2. Ajustar el selector de servidor para incluir:
|
||||||
|
- `comunidad-hispana-01`
|
||||||
|
- `comunidad-hispana-02`
|
||||||
|
- `comunidad-hispana-03`
|
||||||
|
- `all-servers`
|
||||||
|
3. Asegurar que resumen, tops y partidas recientes se cargan desde snapshots rápidos ya preparados.
|
||||||
|
4. Evitar caches frontales que congelen indefinidamente respuestas `found: false`.
|
||||||
|
5. Mantener estados de loading, empty y error, pero con una experiencia más ágil.
|
||||||
|
6. Reflejar correctamente:
|
||||||
|
- servidor seleccionado
|
||||||
|
- rango real usado
|
||||||
|
- fallback semanal si aplica
|
||||||
|
7. No depender de URLs externas de la comunidad.
|
||||||
|
8. Mantener coherencia visual con la página histórica ya existente.
|
||||||
|
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
|
||||||
|
- frontend/historico.html
|
||||||
|
- frontend/assets/js/historico.js
|
||||||
|
- frontend/assets/css/historico.css
|
||||||
|
- backend/app/routes.py
|
||||||
|
- backend/app/payloads.py
|
||||||
|
- backend/README.md
|
||||||
|
|
||||||
|
## Expected Files to Modify
|
||||||
|
- frontend/historico.html
|
||||||
|
- frontend/assets/js/historico.js
|
||||||
|
- frontend/assets/css/historico.css
|
||||||
|
- opcionalmente backend docs mínimas si cambia el contrato visible de consumo
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
- No crear páginas nuevas.
|
||||||
|
- No romper la UI histórica existente.
|
||||||
|
- No introducir frameworks nuevos.
|
||||||
|
- No hacer cambios destructivos.
|
||||||
|
- Mantener el trabajo centrado en velocidad percibida, selector ampliado y consumo de snapshots.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
- La UI histórica soporta #01, #02, #03 y tops globales.
|
||||||
|
- Cambiar de servidor o métrica es rápido.
|
||||||
|
- No se quedan cacheadas indefinidamente respuestas vacías antiguas.
|
||||||
|
- La página consume snapshots precalculados y no agregados pesados en tiempo real.
|
||||||
|
- Los cambios quedan committeados y se hace push si el entorno lo permite.
|
||||||
|
|
||||||
|
## Change Budget
|
||||||
|
- Preferir menos de 5 archivos modificados o creados.
|
||||||
|
- Preferir menos de 220 líneas cambiadas.
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
- El selector histórico se amplió a `comunidad-hispana-01`, `comunidad-hispana-02`, `comunidad-hispana-03` y `all-servers`.
|
||||||
|
- La UI sigue consumiendo los endpoints propios de snapshots precalculados, pero ahora hace prefetch de resumen, partidas recientes y rankings del alcance activo para acelerar los cambios de selector y pestaña.
|
||||||
|
- La caché frontend ya no conserva indefinidamente respuestas con `found: false`; las respuestas negativas vencen rápido y se reintentan automáticamente.
|
||||||
|
- La cabecera y las notas de sección reflejan mejor el alcance activo, incluyendo el caso agregado `Totales / Todos`.
|
||||||
|
- Validación local:
|
||||||
|
- `node --check frontend/assets/js/historico.js`
|
||||||
|
- comprobación de strings y selectores en `frontend/historico.html` y `frontend/assets/js/historico.js` para `comunidad-hispana-03`, `all-servers` y `recent-matches-note`
|
||||||
@@ -200,7 +200,6 @@ solo libreria estandar de Python. Esta base minima sigue el modelo logico de:
|
|||||||
- `historical_players`
|
- `historical_players`
|
||||||
- `historical_player_match_stats`
|
- `historical_player_match_stats`
|
||||||
- `historical_ingestion_runs`
|
- `historical_ingestion_runs`
|
||||||
- `historical_precomputed_snapshots`
|
|
||||||
|
|
||||||
Por defecto el archivo se crea en:
|
Por defecto el archivo se crea en:
|
||||||
|
|
||||||
@@ -220,29 +219,42 @@ no introduce ORM, migraciones ni una decision de almacenamiento productivo.
|
|||||||
|
|
||||||
## Snapshots historicos precalculados
|
## Snapshots historicos precalculados
|
||||||
|
|
||||||
La capa historica incluye ahora una tabla adicional `historical_precomputed_snapshots`
|
La capa historica persiste ahora los snapshots precalculados orientados a UI
|
||||||
en el mismo SQLite local para persistir payloads ya agregados y servirlos sin
|
como archivos JSON independientes en disco, separados del SQLite del historico
|
||||||
recalculo pesado en cada request. Esta capa esta preparada para guardar:
|
bruto. Esta capa esta preparada para guardar:
|
||||||
|
|
||||||
- `server-summary`
|
- `server-summary`
|
||||||
- `weekly-leaderboard` con metricas `kills`, `deaths`, `support` y `matches_over_100_kills`
|
- `weekly-leaderboard` con metricas `kills`, `deaths`, `support` y `matches_over_100_kills`
|
||||||
- `recent-matches`
|
- `recent-matches`
|
||||||
|
|
||||||
Cada snapshot conserva metadatos operativos minimos:
|
Por defecto se escriben bajo:
|
||||||
|
|
||||||
|
```text
|
||||||
|
backend/data/snapshots/<server_key>/
|
||||||
|
```
|
||||||
|
|
||||||
|
Ejemplos:
|
||||||
|
|
||||||
|
- `backend/data/snapshots/comunidad-hispana-01/server-summary.json`
|
||||||
|
- `backend/data/snapshots/comunidad-hispana-01/weekly-kills.json`
|
||||||
|
- `backend/data/snapshots/comunidad-hispana-03/recent-matches.json`
|
||||||
|
- `backend/data/snapshots/all-servers/weekly-support.json`
|
||||||
|
|
||||||
|
Cada archivo conserva metadatos operativos minimos:
|
||||||
|
|
||||||
- `server_key`
|
- `server_key`
|
||||||
- `snapshot_type`
|
- `snapshot_type`
|
||||||
- `metric`
|
- `metric`
|
||||||
- `window`
|
- `window`
|
||||||
- `payload_json`
|
- `payload`
|
||||||
- `generated_at`
|
- `generated_at`
|
||||||
- `source_range_start`
|
- `source_range_start`
|
||||||
- `source_range_end`
|
- `source_range_end`
|
||||||
- `is_stale`
|
- `is_stale`
|
||||||
|
|
||||||
La persistencia usa `upsert` por combinacion de servidor, tipo, metrica y
|
La persistencia usa una identidad de archivo estable por combinacion de
|
||||||
ventana para que la siguiente task pueda refrescar estos snapshots de forma
|
servidor, tipo y metrica para que cada refresh reemplace el artefacto anterior
|
||||||
periodica sin duplicar filas.
|
sin mezclarlo con el historico bruto.
|
||||||
|
|
||||||
## Bootstrap del colector
|
## Bootstrap del colector
|
||||||
|
|
||||||
@@ -478,6 +490,10 @@ 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`
|
||||||
|
|
||||||
|
Ademas de los slugs fisicos de cada scoreboard, la capa historica acepta la
|
||||||
|
clave logica `all-servers` para devolver agregados globales sobre los tres
|
||||||
|
servidores de Comunidad Hispana sin tratarla como un origen CRCON real aparte.
|
||||||
|
|
||||||
La ventana temporal usa semana calendario UTC y solo considera partidas
|
La ventana temporal usa semana calendario UTC y solo considera partidas
|
||||||
cerradas con `ended_at` para no mezclar partidas aun en curso ni filas
|
cerradas con `ended_at` para no mezclar partidas aun en curso ni filas
|
||||||
historicas transitorias. El payload devuelve servidor, rango temporal,
|
historicas transitorias. El payload devuelve servidor, rango temporal,
|
||||||
@@ -502,9 +518,10 @@ conteo de jugadores. `server-summary` agrega volumen historico, jugadores
|
|||||||
unicos, kills, mapas dominantes y rango temporal cubierto. `player-profile`
|
unicos, kills, mapas dominantes y rango temporal cubierto. `player-profile`
|
||||||
deja lista la base de consulta agregada por jugador para futuras vistas.
|
deja lista la base de consulta agregada por jugador para futuras vistas.
|
||||||
|
|
||||||
La familia `/api/historical/snapshots/*` lee directamente la tabla
|
La familia `/api/historical/snapshots/*` lee directamente los archivos JSON
|
||||||
`historical_precomputed_snapshots` y evita recalcular agregados pesados en cada
|
precalculados bajo `backend/data/snapshots/` y evita recalcular agregados
|
||||||
request. Estos endpoints devuelven payloads ligeros listos para frontend con:
|
pesados en cada request. Estos endpoints devuelven payloads ligeros listos para
|
||||||
|
frontend con:
|
||||||
|
|
||||||
- `generated_at`
|
- `generated_at`
|
||||||
- `source_range_start`
|
- `source_range_start`
|
||||||
@@ -522,6 +539,12 @@ request. Estos endpoints devuelven payloads ligeros listos para frontend con:
|
|||||||
- `previous_week_closed_matches`
|
- `previous_week_closed_matches`
|
||||||
- `sufficient_sample`
|
- `sufficient_sample`
|
||||||
|
|
||||||
|
Si un servidor ya tiene historico bruto en `historical_*` pero aun no conserva
|
||||||
|
el archivo precalculado correspondiente en `backend/data/snapshots/`, la API
|
||||||
|
intenta regenerar automaticamente el lote de snapshots de ese servidor antes de
|
||||||
|
responder. Esto evita que un servidor quede bloqueado en `found: false` por una
|
||||||
|
ausencia puntual de persistencia precalculada.
|
||||||
|
|
||||||
`/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
|
||||||
precalculados para una metrica semanal y acepta `limit` para recortar el
|
precalculados para una metrica semanal y acepta `limit` para recortar el
|
||||||
@@ -539,6 +562,7 @@ Fuentes configuradas:
|
|||||||
|
|
||||||
- `https://scoreboard.comunidadhll.es`
|
- `https://scoreboard.comunidadhll.es`
|
||||||
- `https://scoreboard.comunidadhll.es:5443`
|
- `https://scoreboard.comunidadhll.es:5443`
|
||||||
|
- `https://scoreboard.comunidadhll.es:3443`
|
||||||
|
|
||||||
Comandos manuales desde `backend/`:
|
Comandos manuales desde `backend/`:
|
||||||
|
|
||||||
@@ -551,6 +575,7 @@ python -m app.historical_runner --interval 1800
|
|||||||
Flags utiles:
|
Flags utiles:
|
||||||
|
|
||||||
- `--server comunidad-hispana-01` para limitar a un servidor
|
- `--server comunidad-hispana-01` para limitar a un servidor
|
||||||
|
- `--server comunidad-hispana-03` para validar solo el tercer scoreboard historico
|
||||||
- `--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 forzar una pagina concreta en bootstraps largos
|
- `--start-page 4` para forzar una pagina concreta en bootstraps largos
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
"""SQLite persistence for precomputed historical snapshots."""
|
"""File-based persistence for precomputed historical snapshots."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import sqlite3
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -13,41 +12,15 @@ from .historical_snapshots import validate_snapshot_identity
|
|||||||
from .historical_storage import initialize_historical_storage
|
from .historical_storage import initialize_historical_storage
|
||||||
|
|
||||||
|
|
||||||
|
SNAPSHOT_DIRECTORY_NAME = "snapshots"
|
||||||
|
|
||||||
|
|
||||||
def initialize_historical_snapshot_storage(*, db_path: Path | None = None) -> Path:
|
def initialize_historical_snapshot_storage(*, db_path: Path | None = None) -> Path:
|
||||||
"""Create the snapshot table used by precomputed historical payloads."""
|
"""Create the snapshot directory used by precomputed historical payloads."""
|
||||||
resolved_path = initialize_historical_storage(db_path=db_path or get_storage_path())
|
resolved_db_path = initialize_historical_storage(db_path=db_path or get_storage_path())
|
||||||
|
snapshots_root = resolved_db_path.parent / SNAPSHOT_DIRECTORY_NAME
|
||||||
with _connect(resolved_path) as connection:
|
snapshots_root.mkdir(parents=True, exist_ok=True)
|
||||||
connection.executescript(
|
return snapshots_root
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS historical_precomputed_snapshots (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
server_key TEXT NOT NULL,
|
|
||||||
snapshot_type TEXT NOT NULL,
|
|
||||||
metric TEXT NOT NULL DEFAULT '',
|
|
||||||
window TEXT NOT NULL DEFAULT '',
|
|
||||||
payload_json TEXT NOT NULL,
|
|
||||||
generated_at TEXT NOT NULL,
|
|
||||||
source_range_start TEXT,
|
|
||||||
source_range_end TEXT,
|
|
||||||
is_stale INTEGER NOT NULL DEFAULT 0,
|
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
UNIQUE(server_key, snapshot_type, metric, window)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_historical_precomputed_snapshots_lookup
|
|
||||||
ON historical_precomputed_snapshots(
|
|
||||||
server_key,
|
|
||||||
snapshot_type,
|
|
||||||
metric,
|
|
||||||
window,
|
|
||||||
generated_at DESC
|
|
||||||
);
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
return resolved_path
|
|
||||||
|
|
||||||
|
|
||||||
def persist_historical_snapshot(
|
def persist_historical_snapshot(
|
||||||
@@ -63,60 +36,46 @@ def persist_historical_snapshot(
|
|||||||
is_stale: bool = False,
|
is_stale: bool = False,
|
||||||
db_path: Path | None = None,
|
db_path: Path | None = None,
|
||||||
) -> HistoricalSnapshotRecord:
|
) -> HistoricalSnapshotRecord:
|
||||||
"""Insert or replace one persisted historical snapshot."""
|
"""Insert or replace one persisted historical snapshot JSON file."""
|
||||||
if not server_key.strip():
|
normalized_server_key = server_key.strip()
|
||||||
|
if not normalized_server_key:
|
||||||
raise ValueError("server_key is required for historical snapshots.")
|
raise ValueError("server_key is required for historical snapshots.")
|
||||||
|
|
||||||
validate_snapshot_identity(snapshot_type=snapshot_type, metric=metric)
|
validate_snapshot_identity(snapshot_type=snapshot_type, metric=metric)
|
||||||
resolved_path = initialize_historical_snapshot_storage(db_path=db_path)
|
snapshots_root = initialize_historical_snapshot_storage(db_path=db_path)
|
||||||
generated_at_value = generated_at or datetime.now(timezone.utc)
|
generated_at_value = _as_utc(generated_at or datetime.now(timezone.utc))
|
||||||
payload_json = json.dumps(payload, ensure_ascii=True, separators=(",", ":"))
|
payload_json = json.dumps(payload, ensure_ascii=True)
|
||||||
normalized_metric = metric or ""
|
snapshot_path = _build_snapshot_path(
|
||||||
normalized_window = window or ""
|
snapshots_root=snapshots_root,
|
||||||
|
server_key=normalized_server_key,
|
||||||
|
snapshot_type=snapshot_type,
|
||||||
|
metric=metric,
|
||||||
|
)
|
||||||
|
snapshot_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
with _connect(resolved_path) as connection:
|
snapshot_document = {
|
||||||
connection.execute(
|
"server_key": normalized_server_key,
|
||||||
"""
|
"snapshot_type": snapshot_type,
|
||||||
INSERT INTO historical_precomputed_snapshots (
|
"metric": metric,
|
||||||
server_key,
|
"window": window,
|
||||||
snapshot_type,
|
"generated_at": _to_iso(generated_at_value),
|
||||||
metric,
|
"source_range_start": _to_iso(source_range_start),
|
||||||
window,
|
"source_range_end": _to_iso(source_range_end),
|
||||||
payload_json,
|
"is_stale": is_stale,
|
||||||
generated_at,
|
"payload": payload,
|
||||||
source_range_start,
|
}
|
||||||
source_range_end,
|
snapshot_path.write_text(
|
||||||
is_stale
|
json.dumps(snapshot_document, ensure_ascii=True, indent=2) + "\n",
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
encoding="utf-8",
|
||||||
ON CONFLICT(server_key, snapshot_type, metric, window)
|
|
||||||
DO UPDATE SET
|
|
||||||
payload_json = excluded.payload_json,
|
|
||||||
generated_at = excluded.generated_at,
|
|
||||||
source_range_start = excluded.source_range_start,
|
|
||||||
source_range_end = excluded.source_range_end,
|
|
||||||
is_stale = excluded.is_stale,
|
|
||||||
updated_at = CURRENT_TIMESTAMP
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
server_key.strip(),
|
|
||||||
snapshot_type,
|
|
||||||
normalized_metric,
|
|
||||||
normalized_window,
|
|
||||||
payload_json,
|
|
||||||
_to_iso(generated_at_value),
|
|
||||||
_to_iso(source_range_start),
|
|
||||||
_to_iso(source_range_end),
|
|
||||||
1 if is_stale else 0,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return HistoricalSnapshotRecord(
|
return HistoricalSnapshotRecord(
|
||||||
server_key=server_key.strip(),
|
server_key=normalized_server_key,
|
||||||
snapshot_type=snapshot_type,
|
snapshot_type=snapshot_type,
|
||||||
metric=metric,
|
metric=metric,
|
||||||
window=window,
|
window=window,
|
||||||
payload_json=payload_json,
|
payload_json=payload_json,
|
||||||
generated_at=_as_utc(generated_at_value),
|
generated_at=generated_at_value,
|
||||||
source_range_start=_as_utc(source_range_start),
|
source_range_start=_as_utc(source_range_start),
|
||||||
source_range_end=_as_utc(source_range_end),
|
source_range_end=_as_utc(source_range_end),
|
||||||
is_stale=is_stale,
|
is_stale=is_stale,
|
||||||
@@ -158,44 +117,27 @@ def get_historical_snapshot(
|
|||||||
) -> dict[str, object] | None:
|
) -> dict[str, object] | None:
|
||||||
"""Return one persisted snapshot and decoded payload, if present."""
|
"""Return one persisted snapshot and decoded payload, if present."""
|
||||||
validate_snapshot_identity(snapshot_type=snapshot_type, metric=metric)
|
validate_snapshot_identity(snapshot_type=snapshot_type, metric=metric)
|
||||||
resolved_path = initialize_historical_snapshot_storage(db_path=db_path)
|
snapshots_root = initialize_historical_snapshot_storage(db_path=db_path)
|
||||||
|
snapshot_path = _build_snapshot_path(
|
||||||
with _connect(resolved_path) as connection:
|
snapshots_root=snapshots_root,
|
||||||
row = connection.execute(
|
server_key=server_key,
|
||||||
"""
|
snapshot_type=snapshot_type,
|
||||||
SELECT
|
metric=metric,
|
||||||
server_key,
|
)
|
||||||
snapshot_type,
|
if not snapshot_path.exists():
|
||||||
metric,
|
|
||||||
window,
|
|
||||||
payload_json,
|
|
||||||
generated_at,
|
|
||||||
source_range_start,
|
|
||||||
source_range_end,
|
|
||||||
is_stale
|
|
||||||
FROM historical_precomputed_snapshots
|
|
||||||
WHERE server_key = ?
|
|
||||||
AND snapshot_type = ?
|
|
||||||
AND metric = ?
|
|
||||||
AND window = ?
|
|
||||||
""",
|
|
||||||
(server_key, snapshot_type, metric or "", window or ""),
|
|
||||||
).fetchone()
|
|
||||||
|
|
||||||
if row is None:
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
payload = json.loads(row["payload_json"])
|
document = json.loads(snapshot_path.read_text(encoding="utf-8"))
|
||||||
return {
|
return {
|
||||||
"server_key": row["server_key"],
|
"server_key": document.get("server_key"),
|
||||||
"snapshot_type": row["snapshot_type"],
|
"snapshot_type": document.get("snapshot_type"),
|
||||||
"metric": row["metric"] or None,
|
"metric": document.get("metric"),
|
||||||
"window": row["window"] or None,
|
"window": document.get("window"),
|
||||||
"generated_at": row["generated_at"],
|
"generated_at": document.get("generated_at"),
|
||||||
"source_range_start": row["source_range_start"],
|
"source_range_start": document.get("source_range_start"),
|
||||||
"source_range_end": row["source_range_end"],
|
"source_range_end": document.get("source_range_end"),
|
||||||
"is_stale": bool(row["is_stale"]),
|
"is_stale": bool(document.get("is_stale", False)),
|
||||||
"payload": payload,
|
"payload": document.get("payload"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -206,60 +148,74 @@ def list_historical_snapshots(
|
|||||||
db_path: Path | None = None,
|
db_path: Path | None = None,
|
||||||
) -> list[dict[str, object]]:
|
) -> list[dict[str, object]]:
|
||||||
"""List persisted snapshots for validation and operational inspection."""
|
"""List persisted snapshots for validation and operational inspection."""
|
||||||
resolved_path = initialize_historical_snapshot_storage(db_path=db_path)
|
snapshots_root = initialize_historical_snapshot_storage(db_path=db_path)
|
||||||
where_parts: list[str] = []
|
|
||||||
params: list[object] = []
|
|
||||||
|
|
||||||
if server_key:
|
|
||||||
where_parts.append("server_key = ?")
|
|
||||||
params.append(server_key)
|
|
||||||
if snapshot_type:
|
if snapshot_type:
|
||||||
validate_snapshot_identity(snapshot_type=snapshot_type)
|
validate_snapshot_identity(snapshot_type=snapshot_type)
|
||||||
where_parts.append("snapshot_type = ?")
|
|
||||||
params.append(snapshot_type)
|
|
||||||
|
|
||||||
where_sql = ""
|
rows: list[dict[str, object]] = []
|
||||||
if where_parts:
|
for snapshot_path in snapshots_root.glob("*/*.json"):
|
||||||
where_sql = "WHERE " + " AND ".join(where_parts)
|
try:
|
||||||
|
document = json.loads(snapshot_path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
continue
|
||||||
|
|
||||||
with _connect(resolved_path) as connection:
|
if server_key and document.get("server_key") != server_key:
|
||||||
rows = connection.execute(
|
continue
|
||||||
f"""
|
if snapshot_type and document.get("snapshot_type") != snapshot_type:
|
||||||
SELECT
|
continue
|
||||||
server_key,
|
|
||||||
snapshot_type,
|
|
||||||
metric,
|
|
||||||
window,
|
|
||||||
generated_at,
|
|
||||||
source_range_start,
|
|
||||||
source_range_end,
|
|
||||||
is_stale
|
|
||||||
FROM historical_precomputed_snapshots
|
|
||||||
{where_sql}
|
|
||||||
ORDER BY server_key ASC, snapshot_type ASC, generated_at DESC
|
|
||||||
""",
|
|
||||||
params,
|
|
||||||
).fetchall()
|
|
||||||
|
|
||||||
return [
|
rows.append(
|
||||||
{
|
{
|
||||||
"server_key": row["server_key"],
|
"server_key": document.get("server_key"),
|
||||||
"snapshot_type": row["snapshot_type"],
|
"snapshot_type": document.get("snapshot_type"),
|
||||||
"metric": row["metric"] or None,
|
"metric": document.get("metric"),
|
||||||
"window": row["window"] or None,
|
"window": document.get("window"),
|
||||||
"generated_at": row["generated_at"],
|
"generated_at": document.get("generated_at"),
|
||||||
"source_range_start": row["source_range_start"],
|
"source_range_start": document.get("source_range_start"),
|
||||||
"source_range_end": row["source_range_end"],
|
"source_range_end": document.get("source_range_end"),
|
||||||
"is_stale": bool(row["is_stale"]),
|
"is_stale": bool(document.get("is_stale", False)),
|
||||||
}
|
}
|
||||||
for row in rows
|
)
|
||||||
]
|
|
||||||
|
return sorted(
|
||||||
|
rows,
|
||||||
|
key=lambda item: (
|
||||||
|
str(item.get("server_key") or ""),
|
||||||
|
str(item.get("snapshot_type") or ""),
|
||||||
|
str(item.get("metric") or ""),
|
||||||
|
str(item.get("generated_at") or ""),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _connect(db_path: Path) -> sqlite3.Connection:
|
def _build_snapshot_path(
|
||||||
connection = sqlite3.connect(db_path)
|
*,
|
||||||
connection.row_factory = sqlite3.Row
|
snapshots_root: Path,
|
||||||
return connection
|
server_key: str,
|
||||||
|
snapshot_type: str,
|
||||||
|
metric: str | None,
|
||||||
|
) -> Path:
|
||||||
|
return snapshots_root / server_key / _build_snapshot_filename(
|
||||||
|
snapshot_type=snapshot_type,
|
||||||
|
metric=metric,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_snapshot_filename(*, snapshot_type: str, metric: str | None) -> str:
|
||||||
|
if snapshot_type == "server-summary":
|
||||||
|
return "server-summary.json"
|
||||||
|
if snapshot_type == "recent-matches":
|
||||||
|
return "recent-matches.json"
|
||||||
|
if snapshot_type == "weekly-leaderboard":
|
||||||
|
metric_suffix = "matches-over-100-kills" if metric == "matches_over_100_kills" else _slugify(metric or "unknown")
|
||||||
|
return f"weekly-{metric_suffix}.json"
|
||||||
|
metric_suffix = _slugify(metric or "")
|
||||||
|
base_name = _slugify(snapshot_type)
|
||||||
|
return f"{base_name}-{metric_suffix}.json" if metric_suffix else f"{base_name}.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _slugify(value: str) -> str:
|
||||||
|
return value.strip().replace("_", "-").replace(" ", "-").lower()
|
||||||
|
|
||||||
|
|
||||||
def _to_iso(value: datetime | None) -> str | None:
|
def _to_iso(value: datetime | None) -> str | None:
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from datetime import datetime, timezone
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .historical_storage import (
|
from .historical_storage import (
|
||||||
|
ALL_SERVERS_SLUG,
|
||||||
list_historical_server_summaries,
|
list_historical_server_summaries,
|
||||||
list_historical_servers,
|
list_historical_servers,
|
||||||
list_recent_historical_matches,
|
list_recent_historical_matches,
|
||||||
@@ -65,11 +66,13 @@ def validate_snapshot_identity(
|
|||||||
|
|
||||||
def list_snapshot_server_keys(*, db_path: Path | None = None) -> list[str]:
|
def list_snapshot_server_keys(*, db_path: Path | None = None) -> list[str]:
|
||||||
"""Return the historical server slugs that should receive persisted snapshots."""
|
"""Return the historical server slugs that should receive persisted snapshots."""
|
||||||
return [
|
server_keys = [
|
||||||
str(item["slug"])
|
str(item["slug"])
|
||||||
for item in list_historical_servers(db_path=db_path)
|
for item in list_historical_servers(db_path=db_path)
|
||||||
if item.get("slug")
|
if item.get("slug")
|
||||||
]
|
]
|
||||||
|
server_keys.append(ALL_SERVERS_SLUG)
|
||||||
|
return server_keys
|
||||||
|
|
||||||
|
|
||||||
def build_historical_server_snapshots(
|
def build_historical_server_snapshots(
|
||||||
|
|||||||
@@ -28,7 +28,15 @@ DEFAULT_HISTORICAL_SERVERS = (
|
|||||||
scoreboard_base_url="https://scoreboard.comunidadhll.es:5443",
|
scoreboard_base_url="https://scoreboard.comunidadhll.es:5443",
|
||||||
server_number=2,
|
server_number=2,
|
||||||
),
|
),
|
||||||
|
HistoricalServerDefinition(
|
||||||
|
slug="comunidad-hispana-03",
|
||||||
|
display_name="Comunidad Hispana #03",
|
||||||
|
scoreboard_base_url="https://scoreboard.comunidadhll.es:3443",
|
||||||
|
server_number=3,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
ALL_SERVERS_SLUG = "all-servers"
|
||||||
|
ALL_SERVERS_DISPLAY_NAME = "Totales / Todos"
|
||||||
DEFAULT_WEEKLY_WINDOW_DAYS = 7
|
DEFAULT_WEEKLY_WINDOW_DAYS = 7
|
||||||
DEFAULT_REFRESH_OVERLAP_HOURS = 12
|
DEFAULT_REFRESH_OVERLAP_HOURS = 12
|
||||||
SUPPORTED_WEEKLY_LEADERBOARD_METRICS = frozenset(
|
SUPPORTED_WEEKLY_LEADERBOARD_METRICS = frozenset(
|
||||||
@@ -729,7 +737,7 @@ def list_recent_historical_matches(
|
|||||||
resolved_path = initialize_historical_storage(db_path=db_path)
|
resolved_path = initialize_historical_storage(db_path=db_path)
|
||||||
where_clause = ""
|
where_clause = ""
|
||||||
params: list[object] = []
|
params: list[object] = []
|
||||||
if server_slug:
|
if server_slug and not _is_all_servers_selector(server_slug):
|
||||||
where_clause = "WHERE historical_servers.slug = ?"
|
where_clause = "WHERE historical_servers.slug = ?"
|
||||||
params.append(server_slug)
|
params.append(server_slug)
|
||||||
params.append(limit)
|
params.append(limit)
|
||||||
@@ -797,6 +805,9 @@ def list_historical_server_summaries(
|
|||||||
) -> list[dict[str, object]]:
|
) -> list[dict[str, object]]:
|
||||||
"""Return aggregate historical metrics per server."""
|
"""Return aggregate historical metrics per server."""
|
||||||
resolved_path = initialize_historical_storage(db_path=db_path)
|
resolved_path = initialize_historical_storage(db_path=db_path)
|
||||||
|
if _is_all_servers_selector(server_slug):
|
||||||
|
return [_build_all_servers_summary(db_path=resolved_path)]
|
||||||
|
|
||||||
where_clause = ""
|
where_clause = ""
|
||||||
params: list[object] = []
|
params: list[object] = []
|
||||||
if server_slug:
|
if server_slug:
|
||||||
@@ -1102,6 +1113,7 @@ 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)
|
||||||
|
aggregate_all_servers = _is_all_servers_selector(server_id)
|
||||||
current_time = datetime.now(timezone.utc)
|
current_time = datetime.now(timezone.utc)
|
||||||
current_week_start = _start_of_week(current_time)
|
current_week_start = _start_of_week(current_time)
|
||||||
previous_week_start = current_week_start - timedelta(days=DEFAULT_WEEKLY_WINDOW_DAYS)
|
previous_week_start = current_week_start - timedelta(days=DEFAULT_WEEKLY_WINDOW_DAYS)
|
||||||
@@ -1127,13 +1139,34 @@ def list_weekly_leaderboard(
|
|||||||
window_start.isoformat().replace("+00:00", "Z"),
|
window_start.isoformat().replace("+00:00", "Z"),
|
||||||
window_end.isoformat().replace("+00:00", "Z"),
|
window_end.isoformat().replace("+00:00", "Z"),
|
||||||
]
|
]
|
||||||
if server_id:
|
if server_id and not aggregate_all_servers:
|
||||||
normalized_server_id = server_id.strip()
|
normalized_server_id = server_id.strip()
|
||||||
where_clauses.append(
|
where_clauses.append(
|
||||||
"(historical_servers.slug = ? OR CAST(historical_servers.server_number AS TEXT) = ?)"
|
"(historical_servers.slug = ? OR CAST(historical_servers.server_number AS TEXT) = ?)"
|
||||||
)
|
)
|
||||||
params.extend([normalized_server_id, normalized_server_id])
|
params.extend([normalized_server_id, normalized_server_id])
|
||||||
|
|
||||||
|
server_slug_expression = (
|
||||||
|
f"'{ALL_SERVERS_SLUG}'"
|
||||||
|
if aggregate_all_servers
|
||||||
|
else "historical_servers.slug"
|
||||||
|
)
|
||||||
|
server_name_expression = (
|
||||||
|
f"'{ALL_SERVERS_DISPLAY_NAME}'"
|
||||||
|
if aggregate_all_servers
|
||||||
|
else "historical_servers.display_name"
|
||||||
|
)
|
||||||
|
partition_expression = (
|
||||||
|
f"'{ALL_SERVERS_SLUG}'"
|
||||||
|
if aggregate_all_servers
|
||||||
|
else "historical_servers.slug"
|
||||||
|
)
|
||||||
|
group_by_expression = (
|
||||||
|
"historical_players.id"
|
||||||
|
if aggregate_all_servers
|
||||||
|
else "historical_servers.slug, historical_players.id"
|
||||||
|
)
|
||||||
|
|
||||||
metric_sum_expression = {
|
metric_sum_expression = {
|
||||||
"kills": "COALESCE(SUM(historical_player_match_stats.kills), 0)",
|
"kills": "COALESCE(SUM(historical_player_match_stats.kills), 0)",
|
||||||
"deaths": "COALESCE(SUM(historical_player_match_stats.deaths), 0)",
|
"deaths": "COALESCE(SUM(historical_player_match_stats.deaths), 0)",
|
||||||
@@ -1149,15 +1182,15 @@ def list_weekly_leaderboard(
|
|||||||
f"""
|
f"""
|
||||||
WITH ranked_players AS (
|
WITH ranked_players AS (
|
||||||
SELECT
|
SELECT
|
||||||
historical_servers.slug AS server_slug,
|
{server_slug_expression} AS server_slug,
|
||||||
historical_servers.display_name AS server_name,
|
{server_name_expression} AS server_name,
|
||||||
historical_players.stable_player_key,
|
historical_players.stable_player_key,
|
||||||
historical_players.display_name AS player_name,
|
historical_players.display_name AS player_name,
|
||||||
historical_players.steam_id,
|
historical_players.steam_id,
|
||||||
COUNT(DISTINCT historical_matches.id) AS matches_count,
|
COUNT(DISTINCT historical_matches.id) AS matches_count,
|
||||||
{metric_sum_expression} AS metric_value,
|
{metric_sum_expression} AS metric_value,
|
||||||
ROW_NUMBER() OVER (
|
ROW_NUMBER() OVER (
|
||||||
PARTITION BY historical_servers.slug
|
PARTITION BY {partition_expression}
|
||||||
ORDER BY
|
ORDER BY
|
||||||
{metric_sum_expression} DESC,
|
{metric_sum_expression} DESC,
|
||||||
COUNT(DISTINCT historical_matches.id) ASC,
|
COUNT(DISTINCT historical_matches.id) ASC,
|
||||||
@@ -1171,7 +1204,7 @@ def list_weekly_leaderboard(
|
|||||||
INNER JOIN historical_players
|
INNER JOIN historical_players
|
||||||
ON historical_players.id = historical_player_match_stats.historical_player_id
|
ON historical_players.id = historical_player_match_stats.historical_player_id
|
||||||
WHERE {" AND ".join(where_clauses)}
|
WHERE {" AND ".join(where_clauses)}
|
||||||
GROUP BY historical_servers.slug, historical_players.id
|
GROUP BY {group_by_expression}
|
||||||
)
|
)
|
||||||
SELECT *
|
SELECT *
|
||||||
FROM ranked_players
|
FROM ranked_players
|
||||||
@@ -2219,7 +2252,7 @@ def _count_closed_matches_in_window(
|
|||||||
window_start.isoformat().replace("+00:00", "Z"),
|
window_start.isoformat().replace("+00:00", "Z"),
|
||||||
window_end.isoformat().replace("+00:00", "Z"),
|
window_end.isoformat().replace("+00:00", "Z"),
|
||||||
]
|
]
|
||||||
if server_id:
|
if server_id and not _is_all_servers_selector(server_id):
|
||||||
normalized_server_id = server_id.strip()
|
normalized_server_id = server_id.strip()
|
||||||
where_clauses.append(
|
where_clauses.append(
|
||||||
"(historical_servers.slug = ? OR CAST(historical_servers.server_number AS TEXT) = ?)"
|
"(historical_servers.slug = ? OR CAST(historical_servers.server_number AS TEXT) = ?)"
|
||||||
@@ -2253,6 +2286,122 @@ def _classify_coverage_status(
|
|||||||
return "week-plus"
|
return "week-plus"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_all_servers_summary(*, db_path: Path) -> dict[str, object]:
|
||||||
|
per_server_items = list_historical_server_summaries(db_path=db_path)
|
||||||
|
imported_matches_count = sum(int(item.get("matches_count") or 0) for item in per_server_items)
|
||||||
|
unique_players = _count_all_servers_unique_players(db_path=db_path)
|
||||||
|
total_kills = sum(int(item.get("total_kills") or 0) for item in per_server_items)
|
||||||
|
discovered_total_matches = sum(
|
||||||
|
_coerce_int(item.get("backfill", {}).get("discovered_total_matches")) or 0
|
||||||
|
for item in per_server_items
|
||||||
|
)
|
||||||
|
first_points = [
|
||||||
|
item.get("coverage", {}).get("first_match_at")
|
||||||
|
for item in per_server_items
|
||||||
|
if item.get("coverage", {}).get("first_match_at")
|
||||||
|
]
|
||||||
|
last_points = [
|
||||||
|
item.get("coverage", {}).get("last_match_at")
|
||||||
|
for item in per_server_items
|
||||||
|
if item.get("coverage", {}).get("last_match_at")
|
||||||
|
]
|
||||||
|
first_match_at = min(first_points) if first_points else None
|
||||||
|
last_match_at = max(last_points) if last_points else None
|
||||||
|
coverage_days = _calculate_coverage_days(first_match_at, last_match_at)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"server": {
|
||||||
|
"slug": ALL_SERVERS_SLUG,
|
||||||
|
"name": ALL_SERVERS_DISPLAY_NAME,
|
||||||
|
},
|
||||||
|
"matches_count": imported_matches_count,
|
||||||
|
"imported_matches_count": imported_matches_count,
|
||||||
|
"unique_players": unique_players,
|
||||||
|
"total_kills": total_kills,
|
||||||
|
"map_count": _count_all_servers_maps(db_path=db_path),
|
||||||
|
"top_maps": _list_all_servers_top_maps(db_path=db_path, limit=3),
|
||||||
|
"coverage": {
|
||||||
|
"basis": "persisted-import-aggregate",
|
||||||
|
"status": _classify_coverage_status(imported_matches_count, coverage_days),
|
||||||
|
"imported_matches_count": imported_matches_count,
|
||||||
|
"discovered_total_matches": discovered_total_matches or None,
|
||||||
|
"first_match_at": first_match_at,
|
||||||
|
"last_match_at": last_match_at,
|
||||||
|
"coverage_days": coverage_days,
|
||||||
|
},
|
||||||
|
"backfill": {
|
||||||
|
"mode": "aggregate",
|
||||||
|
"server_count": len(per_server_items),
|
||||||
|
"discovered_total_matches": discovered_total_matches or None,
|
||||||
|
"remaining_matches_estimate": (
|
||||||
|
max(discovered_total_matches - imported_matches_count, 0)
|
||||||
|
if discovered_total_matches
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"archive_exhausted": all(
|
||||||
|
bool(item.get("backfill", {}).get("archive_exhausted"))
|
||||||
|
for item in per_server_items
|
||||||
|
),
|
||||||
|
"last_run": None,
|
||||||
|
},
|
||||||
|
"time_range": {
|
||||||
|
"start": first_match_at,
|
||||||
|
"end": last_match_at,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _count_all_servers_unique_players(*, db_path: Path) -> int:
|
||||||
|
with _connect(db_path) as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(DISTINCT historical_players.id) AS unique_players
|
||||||
|
FROM historical_player_match_stats
|
||||||
|
INNER JOIN historical_players
|
||||||
|
ON historical_players.id = historical_player_match_stats.historical_player_id
|
||||||
|
"""
|
||||||
|
).fetchone()
|
||||||
|
return int(row["unique_players"] or 0) if row is not None else 0
|
||||||
|
|
||||||
|
|
||||||
|
def _count_all_servers_maps(*, db_path: Path) -> int:
|
||||||
|
with _connect(db_path) as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(DISTINCT COALESCE(map_pretty_name, map_name)) AS map_count
|
||||||
|
FROM historical_matches
|
||||||
|
"""
|
||||||
|
).fetchone()
|
||||||
|
return int(row["map_count"] or 0) if row is not None else 0
|
||||||
|
|
||||||
|
|
||||||
|
def _list_all_servers_top_maps(*, db_path: Path, limit: int) -> list[dict[str, object]]:
|
||||||
|
with _connect(db_path) as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
COALESCE(map_pretty_name, map_name, 'Mapa no disponible') AS map_name,
|
||||||
|
COUNT(*) AS matches_count
|
||||||
|
FROM historical_matches
|
||||||
|
GROUP BY COALESCE(map_pretty_name, map_name, 'Mapa no disponible')
|
||||||
|
ORDER BY matches_count DESC, map_name ASC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(limit,),
|
||||||
|
).fetchall()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"map_name": row["map_name"],
|
||||||
|
"matches_count": int(row["matches_count"] or 0),
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _is_all_servers_selector(value: str | None) -> bool:
|
||||||
|
return isinstance(value, str) and value.strip() == ALL_SERVERS_SLUG
|
||||||
|
|
||||||
|
|
||||||
def _start_of_week(value: datetime) -> datetime:
|
def _start_of_week(value: datetime) -> datetime:
|
||||||
normalized = value.astimezone(timezone.utc)
|
normalized = value.astimezone(timezone.utc)
|
||||||
midnight = normalized.replace(hour=0, minute=0, second=0, microsecond=0)
|
midnight = normalized.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
|||||||
@@ -13,8 +13,10 @@ from .historical_snapshots import (
|
|||||||
SNAPSHOT_TYPE_RECENT_MATCHES,
|
SNAPSHOT_TYPE_RECENT_MATCHES,
|
||||||
SNAPSHOT_TYPE_SERVER_SUMMARY,
|
SNAPSHOT_TYPE_SERVER_SUMMARY,
|
||||||
SNAPSHOT_TYPE_WEEKLY_LEADERBOARD,
|
SNAPSHOT_TYPE_WEEKLY_LEADERBOARD,
|
||||||
|
generate_and_persist_historical_snapshots,
|
||||||
)
|
)
|
||||||
from .historical_storage import (
|
from .historical_storage import (
|
||||||
|
ALL_SERVERS_SLUG,
|
||||||
get_historical_player_profile,
|
get_historical_player_profile,
|
||||||
list_historical_server_summaries,
|
list_historical_server_summaries,
|
||||||
list_recent_historical_matches,
|
list_recent_historical_matches,
|
||||||
@@ -228,11 +230,20 @@ def build_weekly_leaderboard_payload(
|
|||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
"""Return one weekly historical leaderboard for the requested metric."""
|
"""Return one weekly historical leaderboard for the requested metric."""
|
||||||
result = list_weekly_leaderboard(limit=limit, server_id=server_id, metric=metric)
|
result = list_weekly_leaderboard(limit=limit, server_id=server_id, metric=metric)
|
||||||
|
is_all_servers = server_id == ALL_SERVERS_SLUG
|
||||||
title_by_metric = {
|
title_by_metric = {
|
||||||
"kills": "Top kills semanales por servidor",
|
"kills": "Top kills semanales totales" if is_all_servers else "Top kills semanales por servidor",
|
||||||
"deaths": "Top muertes semanales por servidor",
|
"deaths": "Top muertes semanales totales" if is_all_servers else "Top muertes semanales por servidor",
|
||||||
"support": "Top puntos de soporte semanales por servidor",
|
"support": (
|
||||||
"matches_over_100_kills": "Top partidas de 100+ kills semanales por servidor",
|
"Top puntos de soporte semanales totales"
|
||||||
|
if is_all_servers
|
||||||
|
else "Top puntos de soporte semanales por servidor"
|
||||||
|
),
|
||||||
|
"matches_over_100_kills": (
|
||||||
|
"Top partidas de 100+ kills semanales totales"
|
||||||
|
if is_all_servers
|
||||||
|
else "Top partidas de 100+ kills semanales por servidor"
|
||||||
|
),
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
@@ -320,11 +331,28 @@ def build_weekly_leaderboard_snapshot_payload(
|
|||||||
payload = snapshot.get("payload") if snapshot else {}
|
payload = snapshot.get("payload") if snapshot else {}
|
||||||
items = payload.get("items") if isinstance(payload, dict) else None
|
items = payload.get("items") if isinstance(payload, dict) else None
|
||||||
sliced_items = list(items[:limit]) if isinstance(items, list) else []
|
sliced_items = list(items[:limit]) if isinstance(items, list) else []
|
||||||
|
is_all_servers = server_id == ALL_SERVERS_SLUG
|
||||||
title_by_metric = {
|
title_by_metric = {
|
||||||
"kills": "Snapshot semanal de top kills por servidor",
|
"kills": (
|
||||||
"deaths": "Snapshot semanal de top muertes por servidor",
|
"Snapshot semanal de top kills totales"
|
||||||
"support": "Snapshot semanal de top soporte por servidor",
|
if is_all_servers
|
||||||
"matches_over_100_kills": "Snapshot semanal de partidas 100+ kills por servidor",
|
else "Snapshot semanal de top kills por servidor"
|
||||||
|
),
|
||||||
|
"deaths": (
|
||||||
|
"Snapshot semanal de top muertes totales"
|
||||||
|
if is_all_servers
|
||||||
|
else "Snapshot semanal de top muertes por servidor"
|
||||||
|
),
|
||||||
|
"support": (
|
||||||
|
"Snapshot semanal de top soporte total"
|
||||||
|
if is_all_servers
|
||||||
|
else "Snapshot semanal de top soporte por servidor"
|
||||||
|
),
|
||||||
|
"matches_over_100_kills": (
|
||||||
|
"Snapshot semanal de partidas 100+ kills totales"
|
||||||
|
if is_all_servers
|
||||||
|
else "Snapshot semanal de partidas 100+ kills por servidor"
|
||||||
|
),
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
@@ -397,7 +425,11 @@ def build_historical_server_summary_payload(
|
|||||||
return {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"data": {
|
"data": {
|
||||||
"title": "Cobertura historica importada por servidor",
|
"title": (
|
||||||
|
"Cobertura historica agregada de todos los servidores"
|
||||||
|
if server_slug == ALL_SERVERS_SLUG
|
||||||
|
else "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",
|
"summary_basis": "persisted-import",
|
||||||
@@ -433,6 +465,21 @@ def _get_historical_snapshot_record(
|
|||||||
) -> dict[str, object] | None:
|
) -> dict[str, object] | None:
|
||||||
if not server_key:
|
if not server_key:
|
||||||
return None
|
return None
|
||||||
|
snapshot = get_historical_snapshot(
|
||||||
|
server_key=server_key,
|
||||||
|
snapshot_type=snapshot_type,
|
||||||
|
metric=metric,
|
||||||
|
window=window,
|
||||||
|
)
|
||||||
|
if snapshot is not None:
|
||||||
|
return snapshot
|
||||||
|
|
||||||
|
# Self-heal missing precomputed rows when raw historical data already exists.
|
||||||
|
try:
|
||||||
|
generate_and_persist_historical_snapshots(server_key=server_key)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
return get_historical_snapshot(
|
return get_historical_snapshot(
|
||||||
server_key=server_key,
|
server_key=server_key,
|
||||||
snapshot_type=snapshot_type,
|
snapshot_type=snapshot_type,
|
||||||
|
|||||||
1
backend/data/snapshots/.gitkeep
Normal file
1
backend/data/snapshots/.gitkeep
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -36,6 +36,9 @@ Cada scoreboard representa un servidor distinto:
|
|||||||
- `GET /api/get_public_info` identifica `#02 [ESP] Comunidad Hispana - discord.comunidadhll.es - Spa Onl`
|
- `GET /api/get_public_info` identifica `#02 [ESP] Comunidad Hispana - discord.comunidadhll.es - Spa Onl`
|
||||||
- `public_stats_port`: `7012`
|
- `public_stats_port`: `7012`
|
||||||
- `public_stats_port_https`: `7013`
|
- `public_stats_port_https`: `7013`
|
||||||
|
- `https://scoreboard.comunidadhll.es:3443`
|
||||||
|
- tercer scoreboard comunitario reservado para la identidad estable `comunidad-hispana-03`
|
||||||
|
- la capa de backend ya debe tratarlo como otra fuente CRCON independiente de las de `#01` y `#02`
|
||||||
|
|
||||||
## How Historical Data Is Loaded
|
## How Historical Data Is Loaded
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ CRCON de Comunidad Hispana.
|
|||||||
|
|
||||||
Esta capa cubre solo historico persistido en backend:
|
Esta capa cubre solo historico persistido en backend:
|
||||||
|
|
||||||
- identidad estable de los 2 servidores historicos
|
- identidad estable de los 3 servidores historicos
|
||||||
- partidas cerradas o actualizadas desde CRCON
|
- partidas cerradas o actualizadas desde CRCON
|
||||||
- mapas asociados a esas partidas
|
- mapas asociados a esas partidas
|
||||||
- identidad reutilizable de jugadores
|
- identidad reutilizable de jugadores
|
||||||
@@ -29,11 +29,20 @@ No sustituye ni modifica el flujo actual de snapshots live via A2S.
|
|||||||
- ejemplos:
|
- ejemplos:
|
||||||
- `comunidad-hispana-01`
|
- `comunidad-hispana-01`
|
||||||
- `comunidad-hispana-02`
|
- `comunidad-hispana-02`
|
||||||
|
- `comunidad-hispana-03`
|
||||||
- atributos de soporte:
|
- atributos de soporte:
|
||||||
- `scoreboard_base_url`
|
- `scoreboard_base_url`
|
||||||
- `server_number`
|
- `server_number`
|
||||||
- `source_kind`
|
- `source_kind`
|
||||||
|
|
||||||
|
La capa de lectura y snapshots admite además una clave lógica adicional:
|
||||||
|
|
||||||
|
- `all-servers`
|
||||||
|
|
||||||
|
Esta clave no representa una fila física extra en `historical_servers`; es una
|
||||||
|
vista agregada sobre los tres servidores históricos reales para rankings y
|
||||||
|
resúmenes globales.
|
||||||
|
|
||||||
### Match
|
### Match
|
||||||
|
|
||||||
- tabla: `historical_matches`
|
- tabla: `historical_matches`
|
||||||
@@ -79,8 +88,8 @@ uniforme.
|
|||||||
|
|
||||||
### Precomputed Snapshot
|
### Precomputed Snapshot
|
||||||
|
|
||||||
- tabla: `historical_precomputed_snapshots`
|
- directorio: `backend/data/snapshots/<server_key>/`
|
||||||
- clave estable:
|
- identidad estable:
|
||||||
- `server_key`
|
- `server_key`
|
||||||
- `snapshot_type`
|
- `snapshot_type`
|
||||||
- `metric`
|
- `metric`
|
||||||
@@ -88,7 +97,8 @@ uniforme.
|
|||||||
- razon:
|
- razon:
|
||||||
- permite exponer resumen, rankings y partidas recientes sin recalcular
|
- permite exponer resumen, rankings y partidas recientes sin recalcular
|
||||||
agregados pesados en cada request
|
agregados pesados en cada request
|
||||||
- mantiene metadatos operativos sobre frescura y rango fuente
|
- mantiene metadatos operativos sobre frescura y rango fuente como artefactos
|
||||||
|
JSON inspeccionables
|
||||||
|
|
||||||
## Data Model
|
## Data Model
|
||||||
|
|
||||||
@@ -135,7 +145,7 @@ Metricas por jugador y partida con al menos:
|
|||||||
|
|
||||||
Trazabilidad operativa para bootstrap y refresh incremental.
|
Trazabilidad operativa para bootstrap y refresh incremental.
|
||||||
|
|
||||||
### `historical_precomputed_snapshots`
|
### `backend/data/snapshots/<server_key>/*.json`
|
||||||
|
|
||||||
Payloads JSON precalculados listos para lectura rapida desde API/UI con:
|
Payloads JSON precalculados listos para lectura rapida desde API/UI con:
|
||||||
|
|
||||||
@@ -143,7 +153,7 @@ Payloads JSON precalculados listos para lectura rapida desde API/UI con:
|
|||||||
- `snapshot_type`
|
- `snapshot_type`
|
||||||
- `metric`
|
- `metric`
|
||||||
- `window`
|
- `window`
|
||||||
- `payload_json`
|
- `payload`
|
||||||
- `generated_at`
|
- `generated_at`
|
||||||
- `source_range_start`
|
- `source_range_start`
|
||||||
- `source_range_end`
|
- `source_range_end`
|
||||||
@@ -158,8 +168,8 @@ Payloads JSON precalculados listos para lectura rapida desde API/UI con:
|
|||||||
`(historical_match_id, historical_player_id)`
|
`(historical_match_id, historical_player_id)`
|
||||||
- el refresco incremental usa una ventana de solape temporal para volver a leer
|
- el refresco incremental usa una ventana de solape temporal para volver a leer
|
||||||
partidas recientes y absorber cambios tardios sin rehacer todo el historico
|
partidas recientes y absorber cambios tardios sin rehacer todo el historico
|
||||||
- los snapshots precalculados usan `UPSERT` por identidad logica para refrescar
|
- los snapshots precalculados usan reemplazo por identidad logica de archivo
|
||||||
el payload sin crear duplicados
|
para refrescar el payload sin crear duplicados
|
||||||
|
|
||||||
## Query Readiness
|
## Query Readiness
|
||||||
|
|
||||||
@@ -168,6 +178,7 @@ La estructura soporta ya consultas futuras como:
|
|||||||
- top kills de la ultima semana por servidor
|
- top kills de la ultima semana por servidor
|
||||||
- top muertes, soporte y partidas de 100+ kills desde una capa cacheada
|
- top muertes, soporte y partidas de 100+ kills desde una capa cacheada
|
||||||
- partidas recientes por servidor
|
- partidas recientes por servidor
|
||||||
|
- rankings y resumenes globales con la clave logica `all-servers`
|
||||||
- mapas jugados y frecuencia
|
- mapas jugados y frecuencia
|
||||||
- agregados por jugador sobre ventanas temporales
|
- agregados por jugador sobre ventanas temporales
|
||||||
|
|
||||||
@@ -175,8 +186,9 @@ La estructura soporta ya consultas futuras como:
|
|||||||
|
|
||||||
- live state actual: `server_snapshots` via A2S
|
- live state actual: `server_snapshots` via A2S
|
||||||
- historico persistido: `historical_*` via CRCON scoreboard JSON
|
- historico persistido: `historical_*` via CRCON scoreboard JSON
|
||||||
- snapshots precalculados: `historical_precomputed_snapshots` sobre el mismo
|
- snapshots precalculados: archivos JSON bajo `backend/data/snapshots/`
|
||||||
historico persistido
|
generados desde el mismo historico persistido
|
||||||
|
|
||||||
Ambas lineas comparten el mismo SQLite local de desarrollo para reducir
|
Ambas lineas siguen compartiendo el mismo SQLite local para el estado live y el
|
||||||
complejidad operativa, pero mantienen tablas y contratos separados.
|
historico bruto, pero la capa de snapshots UI queda desacoplada como archivos
|
||||||
|
en disco para simplificar inspeccion, servicio y depuracion.
|
||||||
|
|||||||
@@ -1,8 +1,27 @@
|
|||||||
const HISTORICAL_SERVER_SLUGS = Object.freeze([
|
const HISTORICAL_SERVERS = Object.freeze([
|
||||||
"comunidad-hispana-01",
|
{
|
||||||
"comunidad-hispana-02",
|
slug: "comunidad-hispana-01",
|
||||||
|
label: "Comunidad Hispana #01",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "comunidad-hispana-02",
|
||||||
|
label: "Comunidad Hispana #02",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "comunidad-hispana-03",
|
||||||
|
label: "Comunidad Hispana #03",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "all-servers",
|
||||||
|
label: "Totales / Todos",
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
const HISTORICAL_SERVER_SLUGS = Object.freeze(
|
||||||
|
HISTORICAL_SERVERS.map((server) => server.slug),
|
||||||
|
);
|
||||||
const DEFAULT_HISTORICAL_SERVER = HISTORICAL_SERVER_SLUGS[0];
|
const DEFAULT_HISTORICAL_SERVER = HISTORICAL_SERVER_SLUGS[0];
|
||||||
|
const SNAPSHOT_CACHE_TTL_MS = 60000;
|
||||||
|
const NEGATIVE_SNAPSHOT_CACHE_TTL_MS = 5000;
|
||||||
const LEADERBOARD_METRICS = Object.freeze([
|
const LEADERBOARD_METRICS = Object.freeze([
|
||||||
{
|
{
|
||||||
key: "kills",
|
key: "kills",
|
||||||
@@ -57,6 +76,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
);
|
);
|
||||||
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");
|
||||||
|
const recentNoteNode = document.getElementById("recent-matches-note");
|
||||||
const recentSnapshotMetaNode = document.getElementById(
|
const recentSnapshotMetaNode = document.getElementById(
|
||||||
"recent-matches-snapshot-meta",
|
"recent-matches-snapshot-meta",
|
||||||
);
|
);
|
||||||
@@ -102,28 +122,39 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const prefetchServerSnapshots = (serverSlug) => {
|
||||||
|
void getSummarySnapshot(serverSlug).catch(() => {});
|
||||||
|
void getRecentMatchesSnapshot(serverSlug).catch(() => {});
|
||||||
|
prefetchLeaderboardSnapshots(serverSlug);
|
||||||
|
};
|
||||||
|
|
||||||
const refreshServerContent = async () => {
|
const refreshServerContent = async () => {
|
||||||
const requestId = activeServerRequestId + 1;
|
const requestId = activeServerRequestId + 1;
|
||||||
activeServerRequestId = requestId;
|
activeServerRequestId = requestId;
|
||||||
const activeMetricConfig = getLeaderboardMetricConfig(activeLeaderboardMetric);
|
const activeMetricConfig = getLeaderboardMetricConfig(activeLeaderboardMetric);
|
||||||
|
const activeServerLabel = getHistoricalServerLabel(activeServerSlug);
|
||||||
|
|
||||||
syncActiveButtons(selectorButtons, activeServerSlug);
|
syncActiveButtons(selectorButtons, activeServerSlug);
|
||||||
syncLeaderboardTabs(leaderboardTabButtons, activeLeaderboardMetric);
|
syncLeaderboardTabs(leaderboardTabButtons, activeLeaderboardMetric);
|
||||||
weeklyTitleNode.textContent = activeMetricConfig.title;
|
weeklyTitleNode.textContent = buildLeaderboardTitle(
|
||||||
|
activeMetricConfig,
|
||||||
|
activeServerSlug,
|
||||||
|
);
|
||||||
weeklyValueHeadingNode.textContent = activeMetricConfig.valueHeading;
|
weeklyValueHeadingNode.textContent = activeMetricConfig.valueHeading;
|
||||||
setRangeBadge(rangeNode, "Cargando rango temporal", false);
|
setRangeBadge(rangeNode, "Cargando rango temporal", false);
|
||||||
summaryNoteNode.textContent =
|
summaryNoteNode.textContent = `La vista esta leyendo snapshots precalculados del historico local para ${activeServerLabel}.`;
|
||||||
"La vista esta leyendo snapshots precalculados del historico local.";
|
|
||||||
setSnapshotMeta(summarySnapshotMetaNode, "Cargando snapshot de resumen...");
|
setSnapshotMeta(summarySnapshotMetaNode, "Cargando snapshot de resumen...");
|
||||||
renderSummaryLoading(summaryNode);
|
renderSummaryLoading(summaryNode);
|
||||||
weeklyWindowNoteNode.textContent =
|
weeklyWindowNoteNode.textContent =
|
||||||
"Cargando snapshot semanal del servidor activo...";
|
"Cargando snapshot semanal del alcance activo...";
|
||||||
setSnapshotMeta(weeklySnapshotMetaNode, "Preparando snapshot semanal...");
|
setSnapshotMeta(weeklySnapshotMetaNode, "Preparando snapshot semanal...");
|
||||||
recentListNode.innerHTML = "";
|
recentListNode.innerHTML = "";
|
||||||
|
recentNoteNode.textContent = buildRecentMatchesNote(activeServerSlug);
|
||||||
setState(recentStateNode, "Cargando partidas recientes...");
|
setState(recentStateNode, "Cargando partidas recientes...");
|
||||||
setSnapshotMeta(recentSnapshotMetaNode, "Cargando snapshot de partidas...");
|
setSnapshotMeta(recentSnapshotMetaNode, "Cargando snapshot de partidas...");
|
||||||
|
|
||||||
const cachedLeaderboardPayload = leaderboardCache.get(
|
const cachedLeaderboardPayload = readCachedPayload(
|
||||||
|
leaderboardCache,
|
||||||
buildLeaderboardSnapshotKey(activeServerSlug, activeLeaderboardMetric),
|
buildLeaderboardSnapshotKey(activeServerSlug, activeLeaderboardMetric),
|
||||||
);
|
);
|
||||||
if (cachedLeaderboardPayload) {
|
if (cachedLeaderboardPayload) {
|
||||||
@@ -185,7 +216,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
activeMetricConfig,
|
activeMetricConfig,
|
||||||
);
|
);
|
||||||
|
|
||||||
prefetchLeaderboardSnapshots(targetServerSlug);
|
prefetchServerSnapshots(targetServerSlug);
|
||||||
};
|
};
|
||||||
|
|
||||||
const refreshLeaderboardContent = async () => {
|
const refreshLeaderboardContent = async () => {
|
||||||
@@ -196,10 +227,14 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
const targetMetric = activeLeaderboardMetric;
|
const targetMetric = activeLeaderboardMetric;
|
||||||
|
|
||||||
syncLeaderboardTabs(leaderboardTabButtons, activeLeaderboardMetric);
|
syncLeaderboardTabs(leaderboardTabButtons, activeLeaderboardMetric);
|
||||||
weeklyTitleNode.textContent = metricConfig.title;
|
weeklyTitleNode.textContent = buildLeaderboardTitle(
|
||||||
|
metricConfig,
|
||||||
|
activeServerSlug,
|
||||||
|
);
|
||||||
weeklyValueHeadingNode.textContent = metricConfig.valueHeading;
|
weeklyValueHeadingNode.textContent = metricConfig.valueHeading;
|
||||||
|
|
||||||
const cachedPayload = leaderboardCache.get(
|
const cachedPayload = readCachedPayload(
|
||||||
|
leaderboardCache,
|
||||||
buildLeaderboardSnapshotKey(targetServerSlug, targetMetric),
|
buildLeaderboardSnapshotKey(targetServerSlug, targetMetric),
|
||||||
);
|
);
|
||||||
if (cachedPayload) {
|
if (cachedPayload) {
|
||||||
@@ -218,7 +253,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
weeklyWindowNoteNode.textContent =
|
weeklyWindowNoteNode.textContent =
|
||||||
"Cargando snapshot semanal del servidor activo...";
|
"Cargando snapshot semanal del alcance activo...";
|
||||||
setSnapshotMeta(weeklySnapshotMetaNode, "Cargando snapshot semanal...");
|
setSnapshotMeta(weeklySnapshotMetaNode, "Cargando snapshot semanal...");
|
||||||
setState(weeklyStateNode, "Cargando ranking semanal...");
|
setState(weeklyStateNode, "Cargando ranking semanal...");
|
||||||
weeklyTableNode.hidden = true;
|
weeklyTableNode.hidden = true;
|
||||||
@@ -249,6 +284,14 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
selectorButtons.forEach((button) => {
|
selectorButtons.forEach((button) => {
|
||||||
|
button.addEventListener("mouseenter", () => {
|
||||||
|
const nextServerSlug = normalizeServerSlug(button.dataset.serverSlug);
|
||||||
|
prefetchServerSnapshots(nextServerSlug);
|
||||||
|
});
|
||||||
|
button.addEventListener("focus", () => {
|
||||||
|
const nextServerSlug = normalizeServerSlug(button.dataset.serverSlug);
|
||||||
|
prefetchServerSnapshots(nextServerSlug);
|
||||||
|
});
|
||||||
button.addEventListener("click", () => {
|
button.addEventListener("click", () => {
|
||||||
const nextServerSlug = normalizeServerSlug(button.dataset.serverSlug);
|
const nextServerSlug = normalizeServerSlug(button.dataset.serverSlug);
|
||||||
if (nextServerSlug === activeServerSlug) {
|
if (nextServerSlug === activeServerSlug) {
|
||||||
@@ -278,7 +321,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
prefetchLeaderboardSnapshots(activeServerSlug);
|
prefetchServerSnapshots(activeServerSlug);
|
||||||
void refreshServerContent();
|
void refreshServerContent();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -287,7 +330,7 @@ function hydrateSummary(result, summaryNode, rangeNode, noteNode, snapshotMetaNo
|
|||||||
renderSummaryError(summaryNode);
|
renderSummaryError(summaryNode);
|
||||||
setRangeBadge(rangeNode, "Snapshot de resumen no disponible", false);
|
setRangeBadge(rangeNode, "Snapshot de resumen no disponible", false);
|
||||||
noteNode.textContent =
|
noteNode.textContent =
|
||||||
"No se pudo leer el resumen precalculado para este servidor.";
|
"No se pudo leer el resumen precalculado para el alcance seleccionado.";
|
||||||
setSnapshotMeta(snapshotMetaNode, "Error al leer el snapshot de resumen.");
|
setSnapshotMeta(snapshotMetaNode, "Error al leer el snapshot de resumen.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -298,7 +341,7 @@ function hydrateSummary(result, summaryNode, rangeNode, noteNode, snapshotMetaNo
|
|||||||
renderSummaryEmpty(summaryNode);
|
renderSummaryEmpty(summaryNode);
|
||||||
setRangeBadge(rangeNode, "Sin snapshot de resumen", false);
|
setRangeBadge(rangeNode, "Sin snapshot de resumen", false);
|
||||||
noteNode.textContent =
|
noteNode.textContent =
|
||||||
"Todavia no existe un snapshot de resumen listo para este servidor.";
|
"Todavia no existe un snapshot de resumen listo para el alcance seleccionado.";
|
||||||
setSnapshotMeta(snapshotMetaNode, "Snapshot de resumen pendiente de generacion.");
|
setSnapshotMeta(snapshotMetaNode, "Snapshot de resumen pendiente de generacion.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -318,6 +361,7 @@ function hydrateSummary(result, summaryNode, rangeNode, noteNode, snapshotMetaNo
|
|||||||
"snapshot-precomputed",
|
"snapshot-precomputed",
|
||||||
7,
|
7,
|
||||||
coverage,
|
coverage,
|
||||||
|
summary.server?.slug,
|
||||||
);
|
);
|
||||||
setSnapshotMeta(
|
setSnapshotMeta(
|
||||||
snapshotMetaNode,
|
snapshotMetaNode,
|
||||||
@@ -354,9 +398,9 @@ function hydrateWeeklyLeaderboard(
|
|||||||
snapshotMetaNode,
|
snapshotMetaNode,
|
||||||
metricConfig,
|
metricConfig,
|
||||||
) {
|
) {
|
||||||
titleNode.textContent = metricConfig.title;
|
|
||||||
valueHeadingNode.textContent = metricConfig.valueHeading;
|
valueHeadingNode.textContent = metricConfig.valueHeading;
|
||||||
if (result.status !== "fulfilled") {
|
if (result.status !== "fulfilled") {
|
||||||
|
titleNode.textContent = metricConfig.title;
|
||||||
noteNode.textContent =
|
noteNode.textContent =
|
||||||
"No se pudo leer el snapshot semanal precalculado para esta metrica.";
|
"No se pudo leer el snapshot semanal precalculado para esta metrica.";
|
||||||
setSnapshotMeta(snapshotMetaNode, "Error al leer el snapshot semanal.");
|
setSnapshotMeta(snapshotMetaNode, "Error al leer el snapshot semanal.");
|
||||||
@@ -366,6 +410,7 @@ function hydrateWeeklyLeaderboard(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const payload = result.value?.data;
|
const payload = result.value?.data;
|
||||||
|
titleNode.textContent = buildLeaderboardTitle(metricConfig, payload?.server_slug);
|
||||||
noteNode.textContent = buildWeeklyWindowNote(payload);
|
noteNode.textContent = buildWeeklyWindowNote(payload);
|
||||||
setSnapshotMeta(
|
setSnapshotMeta(
|
||||||
snapshotMetaNode,
|
snapshotMetaNode,
|
||||||
@@ -523,6 +568,13 @@ function normalizeServerSlug(rawValue) {
|
|||||||
return DEFAULT_HISTORICAL_SERVER;
|
return DEFAULT_HISTORICAL_SERVER;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getHistoricalServerLabel(serverSlug) {
|
||||||
|
return (
|
||||||
|
HISTORICAL_SERVERS.find((server) => server.slug === serverSlug)?.label ||
|
||||||
|
HISTORICAL_SERVERS[0].label
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeLeaderboardMetric(rawValue) {
|
function normalizeLeaderboardMetric(rawValue) {
|
||||||
const normalized = typeof rawValue === "string" ? rawValue.trim() : "";
|
const normalized = typeof rawValue === "string" ? rawValue.trim() : "";
|
||||||
if (LEADERBOARD_METRICS.some((metric) => metric.key === normalized)) {
|
if (LEADERBOARD_METRICS.some((metric) => metric.key === normalized)) {
|
||||||
@@ -571,7 +623,7 @@ function buildCoverageBadgeLabel(coverage, timeRange) {
|
|||||||
return rangeLabel;
|
return rangeLabel;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSummaryNote(summaryBasis, weeklyWindowDays, coverage) {
|
function buildSummaryNote(summaryBasis, weeklyWindowDays, coverage, serverSlug) {
|
||||||
const basisLabel =
|
const basisLabel =
|
||||||
summaryBasis === "snapshot-precomputed"
|
summaryBasis === "snapshot-precomputed"
|
||||||
? "el snapshot precalculado del historico local"
|
? "el snapshot precalculado del historico local"
|
||||||
@@ -583,12 +635,15 @@ function buildSummaryNote(summaryBasis, weeklyWindowDays, coverage) {
|
|||||||
if (status === "under-week") {
|
if (status === "under-week") {
|
||||||
return `Este bloque resume ${basisLabel}. Ahora mismo esa cobertura todavia no alcanza ${weeklyWindowLabel}.`;
|
return `Este bloque resume ${basisLabel}. Ahora mismo esa cobertura todavia no alcanza ${weeklyWindowLabel}.`;
|
||||||
}
|
}
|
||||||
|
if (serverSlug === "all-servers") {
|
||||||
|
return `Resumen global servido desde ${basisLabel}.`;
|
||||||
|
}
|
||||||
return `Resumen servido desde ${basisLabel}.`;
|
return `Resumen servido desde ${basisLabel}.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildWeeklyWindowNote(payload) {
|
function buildWeeklyWindowNote(payload) {
|
||||||
if (!payload?.found) {
|
if (!payload?.found) {
|
||||||
return "No existe un snapshot semanal listo para esta metrica en el servidor activo.";
|
return "No existe un snapshot semanal listo para esta metrica en el alcance activo.";
|
||||||
}
|
}
|
||||||
|
|
||||||
const start = formatTimestamp(payload?.window_start);
|
const start = formatTimestamp(payload?.window_start);
|
||||||
@@ -606,6 +661,17 @@ function buildWeeklyWindowNote(payload) {
|
|||||||
return `${windowLabel}: ${start} a ${end}.`;
|
return `${windowLabel}: ${start} a ${end}.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildLeaderboardTitle(metricConfig, serverSlug) {
|
||||||
|
return `${metricConfig.title} · ${getHistoricalServerLabel(serverSlug)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRecentMatchesNote(serverSlug) {
|
||||||
|
if (serverSlug === "all-servers") {
|
||||||
|
return "Lista de cierres ya registrados para todos los servidores.";
|
||||||
|
}
|
||||||
|
return `Lista de cierres ya registrados para ${getHistoricalServerLabel(serverSlug)}.`;
|
||||||
|
}
|
||||||
|
|
||||||
function buildSnapshotMetaText(payload, missingMessage) {
|
function buildSnapshotMetaText(payload, missingMessage) {
|
||||||
if (!payload?.generated_at) {
|
if (!payload?.generated_at) {
|
||||||
return missingMessage;
|
return missingMessage;
|
||||||
@@ -697,8 +763,9 @@ function formatTimestamp(timestamp) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getCachedJson(cache, pendingCache, key, url) {
|
async function getCachedJson(cache, pendingCache, key, url) {
|
||||||
if (cache.has(key)) {
|
const cachedPayload = readCachedPayload(cache, key);
|
||||||
return cache.get(key);
|
if (cachedPayload) {
|
||||||
|
return cachedPayload;
|
||||||
}
|
}
|
||||||
if (pendingCache.has(key)) {
|
if (pendingCache.has(key)) {
|
||||||
return pendingCache.get(key);
|
return pendingCache.get(key);
|
||||||
@@ -706,7 +773,7 @@ async function getCachedJson(cache, pendingCache, key, url) {
|
|||||||
|
|
||||||
const request = fetchJson(url)
|
const request = fetchJson(url)
|
||||||
.then((payload) => {
|
.then((payload) => {
|
||||||
cache.set(key, payload);
|
writeCachedPayload(cache, key, payload);
|
||||||
pendingCache.delete(key);
|
pendingCache.delete(key);
|
||||||
return payload;
|
return payload;
|
||||||
})
|
})
|
||||||
@@ -718,6 +785,33 @@ async function getCachedJson(cache, pendingCache, key, url) {
|
|||||||
return request;
|
return request;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readCachedPayload(cache, key) {
|
||||||
|
const entry = cache.get(key);
|
||||||
|
if (!entry) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.expiresAt <= Date.now()) {
|
||||||
|
cache.delete(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry.payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeCachedPayload(cache, key, payload) {
|
||||||
|
cache.set(key, {
|
||||||
|
payload,
|
||||||
|
expiresAt: Date.now() + resolveSnapshotCacheTtl(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSnapshotCacheTtl(payload) {
|
||||||
|
return payload?.data?.found === false
|
||||||
|
? NEGATIVE_SNAPSHOT_CACHE_TTL_MS
|
||||||
|
: SNAPSHOT_CACHE_TTL_MS;
|
||||||
|
}
|
||||||
|
|
||||||
async function settlePromise(promise) {
|
async function settlePromise(promise) {
|
||||||
try {
|
try {
|
||||||
const value = await promise;
|
const value = await promise;
|
||||||
|
|||||||
@@ -59,6 +59,20 @@
|
|||||||
>
|
>
|
||||||
Comunidad Hispana #02
|
Comunidad Hispana #02
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="historical-selector__button"
|
||||||
|
type="button"
|
||||||
|
data-server-slug="comunidad-hispana-03"
|
||||||
|
>
|
||||||
|
Comunidad Hispana #03
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="historical-selector__button"
|
||||||
|
type="button"
|
||||||
|
data-server-slug="all-servers"
|
||||||
|
>
|
||||||
|
Totales / Todos
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -97,7 +111,7 @@
|
|||||||
<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">Ranking semanal del servidor activo</h2>
|
<h2 id="weekly-ranking-title">Ranking semanal del alcance activo</h2>
|
||||||
<p class="historical-panel__note" id="weekly-window-note">
|
<p class="historical-panel__note" id="weekly-window-note">
|
||||||
Cargando ventana semanal...
|
Cargando ventana semanal...
|
||||||
</p>
|
</p>
|
||||||
@@ -174,8 +188,8 @@
|
|||||||
<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">
|
<p class="historical-panel__note" id="recent-matches-note">
|
||||||
Lista de cierres ya registrados para el servidor activo.
|
Lista de cierres ya registrados para el alcance activo.
|
||||||
</p>
|
</p>
|
||||||
<p class="historical-snapshot-meta" id="recent-matches-snapshot-meta">
|
<p class="historical-snapshot-meta" id="recent-matches-snapshot-meta">
|
||||||
Cargando snapshot de partidas...
|
Cargando snapshot de partidas...
|
||||||
|
|||||||
Reference in New Issue
Block a user