diff --git a/.gitignore b/.gitignore index 628f4b6..06f87c8 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,5 @@ ai/worker.lock # Local backend runtime data backend/data/*.sqlite3 !backend/data/.gitkeep +backend/data/snapshots/** +!backend/data/snapshots/.gitkeep diff --git a/ai/tasks/done/TASK-047-fix-server-02-snapshot-generation.md b/ai/tasks/done/TASK-047-fix-server-02-snapshot-generation.md new file mode 100644 index 0000000..b989d76 --- /dev/null +++ b/ai/tasks/done/TASK-047-fix-server-02-snapshot-generation.md @@ -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 diff --git a/ai/tasks/done/TASK-048-add-server-03-historical-source-and-ingestion.md b/ai/tasks/done/TASK-048-add-server-03-historical-source-and-ingestion.md new file mode 100644 index 0000000..0b55daf --- /dev/null +++ b/ai/tasks/done/TASK-048-add-server-03-historical-source-and-ingestion.md @@ -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. diff --git a/ai/tasks/done/TASK-049-global-all-servers-historical-tops.md b/ai/tasks/done/TASK-049-global-all-servers-historical-tops.md new file mode 100644 index 0000000..2aa7b76 --- /dev/null +++ b/ai/tasks/done/TASK-049-global-all-servers-historical-tops.md @@ -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` diff --git a/ai/tasks/done/TASK-050-file-based-historical-snapshots.md b/ai/tasks/done/TASK-050-file-based-historical-snapshots.md new file mode 100644 index 0000000..e974db3 --- /dev/null +++ b/ai/tasks/done/TASK-050-file-based-historical-snapshots.md @@ -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//`. +- `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` diff --git a/ai/tasks/done/TASK-051-historical-ui-fast-snapshot-consumption.md b/ai/tasks/done/TASK-051-historical-ui-fast-snapshot-consumption.md new file mode 100644 index 0000000..eacf287 --- /dev/null +++ b/ai/tasks/done/TASK-051-historical-ui-fast-snapshot-consumption.md @@ -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` diff --git a/backend/README.md b/backend/README.md index 09e9a3a..39409df 100644 --- a/backend/README.md +++ b/backend/README.md @@ -200,7 +200,6 @@ solo libreria estandar de Python. Esta base minima sigue el modelo logico de: - `historical_players` - `historical_player_match_stats` - `historical_ingestion_runs` -- `historical_precomputed_snapshots` Por defecto el archivo se crea en: @@ -220,29 +219,42 @@ no introduce ORM, migraciones ni una decision de almacenamiento productivo. ## Snapshots historicos precalculados -La capa historica incluye ahora una tabla adicional `historical_precomputed_snapshots` -en el mismo SQLite local para persistir payloads ya agregados y servirlos sin -recalculo pesado en cada request. Esta capa esta preparada para guardar: +La capa historica persiste ahora los snapshots precalculados orientados a UI +como archivos JSON independientes en disco, separados del SQLite del historico +bruto. Esta capa esta preparada para guardar: - `server-summary` - `weekly-leaderboard` con metricas `kills`, `deaths`, `support` y `matches_over_100_kills` - `recent-matches` -Cada snapshot conserva metadatos operativos minimos: +Por defecto se escriben bajo: + +```text +backend/data/snapshots// +``` + +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` - `snapshot_type` - `metric` - `window` -- `payload_json` +- `payload` - `generated_at` - `source_range_start` - `source_range_end` - `is_stale` -La persistencia usa `upsert` por combinacion de servidor, tipo, metrica y -ventana para que la siguiente task pueda refrescar estos snapshots de forma -periodica sin duplicar filas. +La persistencia usa una identidad de archivo estable por combinacion de +servidor, tipo y metrica para que cada refresh reemplace el artefacto anterior +sin mezclarlo con el historico bruto. ## Bootstrap del colector @@ -478,6 +490,10 @@ Parametros opcionales: - `player` en `/api/historical/player-profile` aceptando `stable_player_key`, `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 cerradas con `ended_at` para no mezclar partidas aun en curso ni filas 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` deja lista la base de consulta agregada por jugador para futuras vistas. -La familia `/api/historical/snapshots/*` lee directamente la tabla -`historical_precomputed_snapshots` y evita recalcular agregados pesados en cada -request. Estos endpoints devuelven payloads ligeros listos para frontend con: +La familia `/api/historical/snapshots/*` lee directamente los archivos JSON +precalculados bajo `backend/data/snapshots/` y evita recalcular agregados +pesados en cada request. Estos endpoints devuelven payloads ligeros listos para +frontend con: - `generated_at` - `source_range_start` @@ -522,6 +539,12 @@ request. Estos endpoints devuelven payloads ligeros listos para frontend con: - `previous_week_closed_matches` - `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 servidor. `/api/historical/snapshots/weekly-leaderboard` devuelve `items` ya 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:5443` +- `https://scoreboard.comunidadhll.es:3443` Comandos manuales desde `backend/`: @@ -551,6 +575,7 @@ python -m app.historical_runner --interval 1800 Flags utiles: - `--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 - `--page-size 25` para ajustar paginacion - `--start-page 4` para forzar una pagina concreta en bootstraps largos diff --git a/backend/app/historical_snapshot_storage.py b/backend/app/historical_snapshot_storage.py index 77a980c..bf2e54c 100644 --- a/backend/app/historical_snapshot_storage.py +++ b/backend/app/historical_snapshot_storage.py @@ -1,9 +1,8 @@ -"""SQLite persistence for precomputed historical snapshots.""" +"""File-based persistence for precomputed historical snapshots.""" from __future__ import annotations import json -import sqlite3 from datetime import datetime, timezone from pathlib import Path @@ -13,41 +12,15 @@ from .historical_snapshots import validate_snapshot_identity from .historical_storage import initialize_historical_storage +SNAPSHOT_DIRECTORY_NAME = "snapshots" + + def initialize_historical_snapshot_storage(*, db_path: Path | None = None) -> Path: - """Create the snapshot table used by precomputed historical payloads.""" - resolved_path = initialize_historical_storage(db_path=db_path or get_storage_path()) - - with _connect(resolved_path) as connection: - connection.executescript( - """ - 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 + """Create the snapshot directory used by precomputed historical payloads.""" + resolved_db_path = initialize_historical_storage(db_path=db_path or get_storage_path()) + snapshots_root = resolved_db_path.parent / SNAPSHOT_DIRECTORY_NAME + snapshots_root.mkdir(parents=True, exist_ok=True) + return snapshots_root def persist_historical_snapshot( @@ -63,60 +36,46 @@ def persist_historical_snapshot( is_stale: bool = False, db_path: Path | None = None, ) -> HistoricalSnapshotRecord: - """Insert or replace one persisted historical snapshot.""" - if not server_key.strip(): + """Insert or replace one persisted historical snapshot JSON file.""" + normalized_server_key = server_key.strip() + if not normalized_server_key: raise ValueError("server_key is required for historical snapshots.") validate_snapshot_identity(snapshot_type=snapshot_type, metric=metric) - resolved_path = initialize_historical_snapshot_storage(db_path=db_path) - generated_at_value = generated_at or datetime.now(timezone.utc) - payload_json = json.dumps(payload, ensure_ascii=True, separators=(",", ":")) - normalized_metric = metric or "" - normalized_window = window or "" + snapshots_root = initialize_historical_snapshot_storage(db_path=db_path) + generated_at_value = _as_utc(generated_at or datetime.now(timezone.utc)) + payload_json = json.dumps(payload, ensure_ascii=True) + snapshot_path = _build_snapshot_path( + 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: - connection.execute( - """ - INSERT INTO historical_precomputed_snapshots ( - server_key, - snapshot_type, - metric, - window, - payload_json, - generated_at, - source_range_start, - source_range_end, - is_stale - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - 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, - ), - ) + snapshot_document = { + "server_key": normalized_server_key, + "snapshot_type": snapshot_type, + "metric": metric, + "window": window, + "generated_at": _to_iso(generated_at_value), + "source_range_start": _to_iso(source_range_start), + "source_range_end": _to_iso(source_range_end), + "is_stale": is_stale, + "payload": payload, + } + snapshot_path.write_text( + json.dumps(snapshot_document, ensure_ascii=True, indent=2) + "\n", + encoding="utf-8", + ) return HistoricalSnapshotRecord( - server_key=server_key.strip(), + server_key=normalized_server_key, snapshot_type=snapshot_type, metric=metric, window=window, 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_end=_as_utc(source_range_end), is_stale=is_stale, @@ -158,44 +117,27 @@ def get_historical_snapshot( ) -> dict[str, object] | None: """Return one persisted snapshot and decoded payload, if present.""" validate_snapshot_identity(snapshot_type=snapshot_type, metric=metric) - resolved_path = initialize_historical_snapshot_storage(db_path=db_path) - - with _connect(resolved_path) as connection: - row = connection.execute( - """ - SELECT - server_key, - snapshot_type, - 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: + snapshots_root = initialize_historical_snapshot_storage(db_path=db_path) + snapshot_path = _build_snapshot_path( + snapshots_root=snapshots_root, + server_key=server_key, + snapshot_type=snapshot_type, + metric=metric, + ) + if not snapshot_path.exists(): return None - payload = json.loads(row["payload_json"]) + document = json.loads(snapshot_path.read_text(encoding="utf-8")) return { - "server_key": row["server_key"], - "snapshot_type": row["snapshot_type"], - "metric": row["metric"] or None, - "window": row["window"] or None, - "generated_at": row["generated_at"], - "source_range_start": row["source_range_start"], - "source_range_end": row["source_range_end"], - "is_stale": bool(row["is_stale"]), - "payload": payload, + "server_key": document.get("server_key"), + "snapshot_type": document.get("snapshot_type"), + "metric": document.get("metric"), + "window": document.get("window"), + "generated_at": document.get("generated_at"), + "source_range_start": document.get("source_range_start"), + "source_range_end": document.get("source_range_end"), + "is_stale": bool(document.get("is_stale", False)), + "payload": document.get("payload"), } @@ -206,60 +148,74 @@ def list_historical_snapshots( db_path: Path | None = None, ) -> list[dict[str, object]]: """List persisted snapshots for validation and operational inspection.""" - resolved_path = 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) + snapshots_root = initialize_historical_snapshot_storage(db_path=db_path) if snapshot_type: validate_snapshot_identity(snapshot_type=snapshot_type) - where_parts.append("snapshot_type = ?") - params.append(snapshot_type) - where_sql = "" - if where_parts: - where_sql = "WHERE " + " AND ".join(where_parts) + rows: list[dict[str, object]] = [] + for snapshot_path in snapshots_root.glob("*/*.json"): + try: + document = json.loads(snapshot_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + continue - with _connect(resolved_path) as connection: - rows = connection.execute( - f""" - SELECT - 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() + if server_key and document.get("server_key") != server_key: + continue + if snapshot_type and document.get("snapshot_type") != snapshot_type: + continue - return [ - { - "server_key": row["server_key"], - "snapshot_type": row["snapshot_type"], - "metric": row["metric"] or None, - "window": row["window"] or None, - "generated_at": row["generated_at"], - "source_range_start": row["source_range_start"], - "source_range_end": row["source_range_end"], - "is_stale": bool(row["is_stale"]), - } - for row in rows - ] + rows.append( + { + "server_key": document.get("server_key"), + "snapshot_type": document.get("snapshot_type"), + "metric": document.get("metric"), + "window": document.get("window"), + "generated_at": document.get("generated_at"), + "source_range_start": document.get("source_range_start"), + "source_range_end": document.get("source_range_end"), + "is_stale": bool(document.get("is_stale", False)), + } + ) + + 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: - connection = sqlite3.connect(db_path) - connection.row_factory = sqlite3.Row - return connection +def _build_snapshot_path( + *, + snapshots_root: Path, + 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: diff --git a/backend/app/historical_snapshots.py b/backend/app/historical_snapshots.py index 13db91e..ed1d715 100644 --- a/backend/app/historical_snapshots.py +++ b/backend/app/historical_snapshots.py @@ -6,6 +6,7 @@ from datetime import datetime, timezone from pathlib import Path from .historical_storage import ( + ALL_SERVERS_SLUG, list_historical_server_summaries, list_historical_servers, list_recent_historical_matches, @@ -65,11 +66,13 @@ def validate_snapshot_identity( def list_snapshot_server_keys(*, db_path: Path | None = None) -> list[str]: """Return the historical server slugs that should receive persisted snapshots.""" - return [ + server_keys = [ str(item["slug"]) for item in list_historical_servers(db_path=db_path) if item.get("slug") ] + server_keys.append(ALL_SERVERS_SLUG) + return server_keys def build_historical_server_snapshots( diff --git a/backend/app/historical_storage.py b/backend/app/historical_storage.py index 4114d74..e521c15 100644 --- a/backend/app/historical_storage.py +++ b/backend/app/historical_storage.py @@ -28,7 +28,15 @@ DEFAULT_HISTORICAL_SERVERS = ( scoreboard_base_url="https://scoreboard.comunidadhll.es:5443", 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_REFRESH_OVERLAP_HOURS = 12 SUPPORTED_WEEKLY_LEADERBOARD_METRICS = frozenset( @@ -729,7 +737,7 @@ def list_recent_historical_matches( resolved_path = initialize_historical_storage(db_path=db_path) where_clause = "" params: list[object] = [] - if server_slug: + if server_slug and not _is_all_servers_selector(server_slug): where_clause = "WHERE historical_servers.slug = ?" params.append(server_slug) params.append(limit) @@ -797,6 +805,9 @@ def list_historical_server_summaries( ) -> list[dict[str, object]]: """Return aggregate historical metrics per server.""" 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 = "" params: list[object] = [] if server_slug: @@ -1102,6 +1113,7 @@ def list_weekly_leaderboard( ) -> dict[str, object]: """Return ranked weekly leaderboard totals from persisted historical match stats.""" resolved_path = initialize_historical_storage(db_path=db_path) + aggregate_all_servers = _is_all_servers_selector(server_id) current_time = datetime.now(timezone.utc) current_week_start = _start_of_week(current_time) 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_end.isoformat().replace("+00:00", "Z"), ] - if server_id: + if server_id and not aggregate_all_servers: normalized_server_id = server_id.strip() where_clauses.append( "(historical_servers.slug = ? OR CAST(historical_servers.server_number AS TEXT) = ?)" ) params.extend([normalized_server_id, normalized_server_id]) + server_slug_expression = ( + f"'{ALL_SERVERS_SLUG}'" + if aggregate_all_servers + else "historical_servers.slug" + ) + server_name_expression = ( + f"'{ALL_SERVERS_DISPLAY_NAME}'" + if aggregate_all_servers + else "historical_servers.display_name" + ) + partition_expression = ( + f"'{ALL_SERVERS_SLUG}'" + if aggregate_all_servers + else "historical_servers.slug" + ) + group_by_expression = ( + "historical_players.id" + if aggregate_all_servers + else "historical_servers.slug, historical_players.id" + ) + metric_sum_expression = { "kills": "COALESCE(SUM(historical_player_match_stats.kills), 0)", "deaths": "COALESCE(SUM(historical_player_match_stats.deaths), 0)", @@ -1149,15 +1182,15 @@ def list_weekly_leaderboard( f""" WITH ranked_players AS ( SELECT - historical_servers.slug AS server_slug, - historical_servers.display_name AS server_name, + {server_slug_expression} AS server_slug, + {server_name_expression} AS server_name, historical_players.stable_player_key, historical_players.display_name AS player_name, historical_players.steam_id, COUNT(DISTINCT historical_matches.id) AS matches_count, {metric_sum_expression} AS metric_value, ROW_NUMBER() OVER ( - PARTITION BY historical_servers.slug + PARTITION BY {partition_expression} ORDER BY {metric_sum_expression} DESC, COUNT(DISTINCT historical_matches.id) ASC, @@ -1171,7 +1204,7 @@ def list_weekly_leaderboard( INNER JOIN historical_players ON historical_players.id = historical_player_match_stats.historical_player_id WHERE {" AND ".join(where_clauses)} - GROUP BY historical_servers.slug, historical_players.id + GROUP BY {group_by_expression} ) SELECT * FROM ranked_players @@ -2219,7 +2252,7 @@ def _count_closed_matches_in_window( window_start.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() where_clauses.append( "(historical_servers.slug = ? OR CAST(historical_servers.server_number AS TEXT) = ?)" @@ -2253,6 +2286,122 @@ def _classify_coverage_status( 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: normalized = value.astimezone(timezone.utc) midnight = normalized.replace(hour=0, minute=0, second=0, microsecond=0) diff --git a/backend/app/payloads.py b/backend/app/payloads.py index 4376c28..71bff6f 100644 --- a/backend/app/payloads.py +++ b/backend/app/payloads.py @@ -13,8 +13,10 @@ from .historical_snapshots import ( SNAPSHOT_TYPE_RECENT_MATCHES, SNAPSHOT_TYPE_SERVER_SUMMARY, SNAPSHOT_TYPE_WEEKLY_LEADERBOARD, + generate_and_persist_historical_snapshots, ) from .historical_storage import ( + ALL_SERVERS_SLUG, get_historical_player_profile, list_historical_server_summaries, list_recent_historical_matches, @@ -228,11 +230,20 @@ def build_weekly_leaderboard_payload( ) -> dict[str, object]: """Return one weekly historical leaderboard for the requested metric.""" result = list_weekly_leaderboard(limit=limit, server_id=server_id, metric=metric) + is_all_servers = server_id == ALL_SERVERS_SLUG title_by_metric = { - "kills": "Top kills semanales por servidor", - "deaths": "Top muertes semanales por servidor", - "support": "Top puntos de soporte semanales por servidor", - "matches_over_100_kills": "Top partidas de 100+ kills semanales por servidor", + "kills": "Top kills semanales totales" if is_all_servers else "Top kills semanales por servidor", + "deaths": "Top muertes semanales totales" if is_all_servers else "Top muertes semanales por servidor", + "support": ( + "Top puntos de soporte semanales totales" + if is_all_servers + else "Top puntos de soporte semanales por servidor" + ), + "matches_over_100_kills": ( + "Top partidas de 100+ kills semanales totales" + if is_all_servers + else "Top partidas de 100+ kills semanales por servidor" + ), } return { "status": "ok", @@ -320,11 +331,28 @@ def build_weekly_leaderboard_snapshot_payload( payload = snapshot.get("payload") if snapshot else {} items = payload.get("items") if isinstance(payload, dict) else None sliced_items = list(items[:limit]) if isinstance(items, list) else [] + is_all_servers = server_id == ALL_SERVERS_SLUG title_by_metric = { - "kills": "Snapshot semanal de top kills por servidor", - "deaths": "Snapshot semanal de top muertes por servidor", - "support": "Snapshot semanal de top soporte por servidor", - "matches_over_100_kills": "Snapshot semanal de partidas 100+ kills por servidor", + "kills": ( + "Snapshot semanal de top kills totales" + if is_all_servers + else "Snapshot semanal de top kills por servidor" + ), + "deaths": ( + "Snapshot semanal de top muertes totales" + if is_all_servers + else "Snapshot semanal de top muertes por servidor" + ), + "support": ( + "Snapshot semanal de top soporte total" + if is_all_servers + else "Snapshot semanal de top soporte por servidor" + ), + "matches_over_100_kills": ( + "Snapshot semanal de partidas 100+ kills totales" + if is_all_servers + else "Snapshot semanal de partidas 100+ kills por servidor" + ), } return { "status": "ok", @@ -397,7 +425,11 @@ def build_historical_server_summary_payload( return { "status": "ok", "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", "source": "historical-crcon-storage", "summary_basis": "persisted-import", @@ -433,6 +465,21 @@ def _get_historical_snapshot_record( ) -> dict[str, object] | None: if not server_key: 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( server_key=server_key, snapshot_type=snapshot_type, diff --git a/backend/data/snapshots/.gitkeep b/backend/data/snapshots/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/data/snapshots/.gitkeep @@ -0,0 +1 @@ + diff --git a/docs/historical-crcon-source-discovery.md b/docs/historical-crcon-source-discovery.md index 98d5742..1291dcf 100644 --- a/docs/historical-crcon-source-discovery.md +++ b/docs/historical-crcon-source-discovery.md @@ -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` - `public_stats_port`: `7012` - `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 diff --git a/docs/historical-domain-model.md b/docs/historical-domain-model.md index 9938b4e..d9c2864 100644 --- a/docs/historical-domain-model.md +++ b/docs/historical-domain-model.md @@ -10,7 +10,7 @@ CRCON de Comunidad Hispana. 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 - mapas asociados a esas partidas - identidad reutilizable de jugadores @@ -29,11 +29,20 @@ No sustituye ni modifica el flujo actual de snapshots live via A2S. - ejemplos: - `comunidad-hispana-01` - `comunidad-hispana-02` + - `comunidad-hispana-03` - atributos de soporte: - `scoreboard_base_url` - `server_number` - `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 - tabla: `historical_matches` @@ -79,8 +88,8 @@ uniforme. ### Precomputed Snapshot -- tabla: `historical_precomputed_snapshots` -- clave estable: +- directorio: `backend/data/snapshots//` +- identidad estable: - `server_key` - `snapshot_type` - `metric` @@ -88,7 +97,8 @@ uniforme. - razon: - permite exponer resumen, rankings y partidas recientes sin recalcular 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 @@ -135,7 +145,7 @@ Metricas por jugador y partida con al menos: Trazabilidad operativa para bootstrap y refresh incremental. -### `historical_precomputed_snapshots` +### `backend/data/snapshots//*.json` 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` - `metric` - `window` -- `payload_json` +- `payload` - `generated_at` - `source_range_start` - `source_range_end` @@ -158,8 +168,8 @@ Payloads JSON precalculados listos para lectura rapida desde API/UI con: `(historical_match_id, historical_player_id)` - el refresco incremental usa una ventana de solape temporal para volver a leer partidas recientes y absorber cambios tardios sin rehacer todo el historico -- los snapshots precalculados usan `UPSERT` por identidad logica para refrescar - el payload sin crear duplicados +- los snapshots precalculados usan reemplazo por identidad logica de archivo + para refrescar el payload sin crear duplicados ## Query Readiness @@ -168,6 +178,7 @@ La estructura soporta ya consultas futuras como: - top kills de la ultima semana por servidor - top muertes, soporte y partidas de 100+ kills desde una capa cacheada - partidas recientes por servidor +- rankings y resumenes globales con la clave logica `all-servers` - mapas jugados y frecuencia - agregados por jugador sobre ventanas temporales @@ -175,8 +186,9 @@ La estructura soporta ya consultas futuras como: - live state actual: `server_snapshots` via A2S - historico persistido: `historical_*` via CRCON scoreboard JSON -- snapshots precalculados: `historical_precomputed_snapshots` sobre el mismo - historico persistido +- snapshots precalculados: archivos JSON bajo `backend/data/snapshots/` + generados desde el mismo historico persistido -Ambas lineas comparten el mismo SQLite local de desarrollo para reducir -complejidad operativa, pero mantienen tablas y contratos separados. +Ambas lineas siguen compartiendo el mismo SQLite local para el estado live y el +historico bruto, pero la capa de snapshots UI queda desacoplada como archivos +en disco para simplificar inspeccion, servicio y depuracion. diff --git a/frontend/assets/js/historico.js b/frontend/assets/js/historico.js index 4d7c37a..62bf042 100644 --- a/frontend/assets/js/historico.js +++ b/frontend/assets/js/historico.js @@ -1,8 +1,27 @@ -const HISTORICAL_SERVER_SLUGS = Object.freeze([ - "comunidad-hispana-01", - "comunidad-hispana-02", +const HISTORICAL_SERVERS = Object.freeze([ + { + 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 SNAPSHOT_CACHE_TTL_MS = 60000; +const NEGATIVE_SNAPSHOT_CACHE_TTL_MS = 5000; const LEADERBOARD_METRICS = Object.freeze([ { key: "kills", @@ -57,6 +76,7 @@ document.addEventListener("DOMContentLoaded", () => { ); const recentStateNode = document.getElementById("recent-matches-state"); const recentListNode = document.getElementById("recent-matches-list"); + const recentNoteNode = document.getElementById("recent-matches-note"); const recentSnapshotMetaNode = document.getElementById( "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 requestId = activeServerRequestId + 1; activeServerRequestId = requestId; const activeMetricConfig = getLeaderboardMetricConfig(activeLeaderboardMetric); + const activeServerLabel = getHistoricalServerLabel(activeServerSlug); syncActiveButtons(selectorButtons, activeServerSlug); syncLeaderboardTabs(leaderboardTabButtons, activeLeaderboardMetric); - weeklyTitleNode.textContent = activeMetricConfig.title; + weeklyTitleNode.textContent = buildLeaderboardTitle( + activeMetricConfig, + activeServerSlug, + ); weeklyValueHeadingNode.textContent = activeMetricConfig.valueHeading; setRangeBadge(rangeNode, "Cargando rango temporal", false); - summaryNoteNode.textContent = - "La vista esta leyendo snapshots precalculados del historico local."; + summaryNoteNode.textContent = `La vista esta leyendo snapshots precalculados del historico local para ${activeServerLabel}.`; setSnapshotMeta(summarySnapshotMetaNode, "Cargando snapshot de resumen..."); renderSummaryLoading(summaryNode); weeklyWindowNoteNode.textContent = - "Cargando snapshot semanal del servidor activo..."; + "Cargando snapshot semanal del alcance activo..."; setSnapshotMeta(weeklySnapshotMetaNode, "Preparando snapshot semanal..."); recentListNode.innerHTML = ""; + recentNoteNode.textContent = buildRecentMatchesNote(activeServerSlug); setState(recentStateNode, "Cargando partidas recientes..."); setSnapshotMeta(recentSnapshotMetaNode, "Cargando snapshot de partidas..."); - const cachedLeaderboardPayload = leaderboardCache.get( + const cachedLeaderboardPayload = readCachedPayload( + leaderboardCache, buildLeaderboardSnapshotKey(activeServerSlug, activeLeaderboardMetric), ); if (cachedLeaderboardPayload) { @@ -185,7 +216,7 @@ document.addEventListener("DOMContentLoaded", () => { activeMetricConfig, ); - prefetchLeaderboardSnapshots(targetServerSlug); + prefetchServerSnapshots(targetServerSlug); }; const refreshLeaderboardContent = async () => { @@ -196,10 +227,14 @@ document.addEventListener("DOMContentLoaded", () => { const targetMetric = activeLeaderboardMetric; syncLeaderboardTabs(leaderboardTabButtons, activeLeaderboardMetric); - weeklyTitleNode.textContent = metricConfig.title; + weeklyTitleNode.textContent = buildLeaderboardTitle( + metricConfig, + activeServerSlug, + ); weeklyValueHeadingNode.textContent = metricConfig.valueHeading; - const cachedPayload = leaderboardCache.get( + const cachedPayload = readCachedPayload( + leaderboardCache, buildLeaderboardSnapshotKey(targetServerSlug, targetMetric), ); if (cachedPayload) { @@ -218,7 +253,7 @@ document.addEventListener("DOMContentLoaded", () => { } weeklyWindowNoteNode.textContent = - "Cargando snapshot semanal del servidor activo..."; + "Cargando snapshot semanal del alcance activo..."; setSnapshotMeta(weeklySnapshotMetaNode, "Cargando snapshot semanal..."); setState(weeklyStateNode, "Cargando ranking semanal..."); weeklyTableNode.hidden = true; @@ -249,6 +284,14 @@ document.addEventListener("DOMContentLoaded", () => { }; 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", () => { const nextServerSlug = normalizeServerSlug(button.dataset.serverSlug); if (nextServerSlug === activeServerSlug) { @@ -278,7 +321,7 @@ document.addEventListener("DOMContentLoaded", () => { }); }); - prefetchLeaderboardSnapshots(activeServerSlug); + prefetchServerSnapshots(activeServerSlug); void refreshServerContent(); }); @@ -287,7 +330,7 @@ function hydrateSummary(result, summaryNode, rangeNode, noteNode, snapshotMetaNo renderSummaryError(summaryNode); setRangeBadge(rangeNode, "Snapshot de resumen no disponible", false); 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."); return; } @@ -298,7 +341,7 @@ function hydrateSummary(result, summaryNode, rangeNode, noteNode, snapshotMetaNo renderSummaryEmpty(summaryNode); setRangeBadge(rangeNode, "Sin snapshot de resumen", false); 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."); return; } @@ -318,6 +361,7 @@ function hydrateSummary(result, summaryNode, rangeNode, noteNode, snapshotMetaNo "snapshot-precomputed", 7, coverage, + summary.server?.slug, ); setSnapshotMeta( snapshotMetaNode, @@ -354,9 +398,9 @@ function hydrateWeeklyLeaderboard( snapshotMetaNode, metricConfig, ) { - titleNode.textContent = metricConfig.title; valueHeadingNode.textContent = metricConfig.valueHeading; if (result.status !== "fulfilled") { + titleNode.textContent = metricConfig.title; noteNode.textContent = "No se pudo leer el snapshot semanal precalculado para esta metrica."; setSnapshotMeta(snapshotMetaNode, "Error al leer el snapshot semanal."); @@ -366,6 +410,7 @@ function hydrateWeeklyLeaderboard( } const payload = result.value?.data; + titleNode.textContent = buildLeaderboardTitle(metricConfig, payload?.server_slug); noteNode.textContent = buildWeeklyWindowNote(payload); setSnapshotMeta( snapshotMetaNode, @@ -523,6 +568,13 @@ function normalizeServerSlug(rawValue) { return DEFAULT_HISTORICAL_SERVER; } +function getHistoricalServerLabel(serverSlug) { + return ( + HISTORICAL_SERVERS.find((server) => server.slug === serverSlug)?.label || + HISTORICAL_SERVERS[0].label + ); +} + function normalizeLeaderboardMetric(rawValue) { const normalized = typeof rawValue === "string" ? rawValue.trim() : ""; if (LEADERBOARD_METRICS.some((metric) => metric.key === normalized)) { @@ -571,7 +623,7 @@ function buildCoverageBadgeLabel(coverage, timeRange) { return rangeLabel; } -function buildSummaryNote(summaryBasis, weeklyWindowDays, coverage) { +function buildSummaryNote(summaryBasis, weeklyWindowDays, coverage, serverSlug) { const basisLabel = summaryBasis === "snapshot-precomputed" ? "el snapshot precalculado del historico local" @@ -583,12 +635,15 @@ function buildSummaryNote(summaryBasis, weeklyWindowDays, coverage) { if (status === "under-week") { 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}.`; } function buildWeeklyWindowNote(payload) { 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); @@ -606,6 +661,17 @@ function buildWeeklyWindowNote(payload) { 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) { if (!payload?.generated_at) { return missingMessage; @@ -697,8 +763,9 @@ function formatTimestamp(timestamp) { } async function getCachedJson(cache, pendingCache, key, url) { - if (cache.has(key)) { - return cache.get(key); + const cachedPayload = readCachedPayload(cache, key); + if (cachedPayload) { + return cachedPayload; } if (pendingCache.has(key)) { return pendingCache.get(key); @@ -706,7 +773,7 @@ async function getCachedJson(cache, pendingCache, key, url) { const request = fetchJson(url) .then((payload) => { - cache.set(key, payload); + writeCachedPayload(cache, key, payload); pendingCache.delete(key); return payload; }) @@ -718,6 +785,33 @@ async function getCachedJson(cache, pendingCache, key, url) { 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) { try { const value = await promise; diff --git a/frontend/historico.html b/frontend/historico.html index 9e295ee..ca86c68 100644 --- a/frontend/historico.html +++ b/frontend/historico.html @@ -59,6 +59,20 @@ > Comunidad Hispana #02 + + @@ -97,7 +111,7 @@

Ranking semanal

-

Ranking semanal del servidor activo

+

Ranking semanal del alcance activo

Cargando ventana semanal...

@@ -174,8 +188,8 @@

Partidas recientes

Ultimos cierres registrados

-

- Lista de cierres ya registrados para el servidor activo. +

+ Lista de cierres ya registrados para el alcance activo.

Cargando snapshot de partidas...