Process historical snapshot tasks 047-051

This commit is contained in:
devRaGonSa
2026-03-23 13:29:06 +01:00
parent 1ea1d4b3c6
commit 68de2d955a
16 changed files with 939 additions and 227 deletions

2
.gitignore vendored
View File

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

View 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

View File

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

View 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`

View 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`

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@

View File

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

View File

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

View File

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

View File

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