Add historical UI and backend API slice
This commit is contained in:
90
ai/tasks/done/TASK-033-historical-weekly-top-kills-ui.md
Normal file
90
ai/tasks/done/TASK-033-historical-weekly-top-kills-ui.md
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# TASK-033-historical-weekly-top-kills-ui
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Crear la primera UI histórica propia del proyecto para mostrar el ranking de jugadores con más kills de la última semana por servidor, consumiendo la API histórica interna del backend y sin depender de páginas externas de la comunidad.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
El proyecto ya dispone de:
|
||||||
|
- live status de servidores vía A2S
|
||||||
|
- capa histórica propia persistida
|
||||||
|
- validación de calidad histórica completada
|
||||||
|
- endpoint histórico `GET /api/historical/weekly-top-kills`
|
||||||
|
|
||||||
|
Con la calidad del histórico ya validada, el siguiente paso es exponer una primera vista propia y útil para el usuario final. Esta UI debe ser del propio proyecto, no una duplicación o incrustación de la web de la comunidad.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
1. Revisar el endpoint actual `GET /api/historical/weekly-top-kills` y su payload real.
|
||||||
|
2. Diseñar una primera UI histórica propia, simple y clara, para mostrar rankings semanales.
|
||||||
|
3. Implementar esta UI en una página propia del proyecto, por ejemplo:
|
||||||
|
- `frontend/historico.html`
|
||||||
|
o una ruta equivalente coherente con la estructura actual del frontend.
|
||||||
|
4. Añadir un selector o control claro para alternar entre los 2 servidores reales de la comunidad.
|
||||||
|
5. Consumir la API histórica propia del backend, sin depender de URLs públicas de la comunidad.
|
||||||
|
6. Mostrar, como mínimo:
|
||||||
|
- posición
|
||||||
|
- nombre de jugador
|
||||||
|
- kills semanales
|
||||||
|
- servidor seleccionado
|
||||||
|
- rango temporal usado si el payload lo expone o puede presentarse de forma clara
|
||||||
|
7. Añadir estados de:
|
||||||
|
- carga
|
||||||
|
- vacío
|
||||||
|
- error
|
||||||
|
- sin datos históricos suficientes
|
||||||
|
8. Mantener la estética coherente con la landing actual.
|
||||||
|
9. No mezclar esta vista con estado live A2S salvo referencia mínima si fuera útil.
|
||||||
|
10. Al completar la implementación:
|
||||||
|
- dejar el repositorio consistente
|
||||||
|
- hacer commit
|
||||||
|
- hacer push al remoto si el entorno lo permite
|
||||||
|
|
||||||
|
## Files to Read First
|
||||||
|
- AGENTS.md
|
||||||
|
- ai/repo-context.md
|
||||||
|
- ai/architecture-index.md
|
||||||
|
- docs/historical-domain-model.md
|
||||||
|
- docs/historical-data-quality-notes.md
|
||||||
|
- backend/README.md
|
||||||
|
- backend/app/routes.py
|
||||||
|
- backend/app/payloads.py
|
||||||
|
- frontend/index.html
|
||||||
|
- frontend/assets/css/styles.css
|
||||||
|
- frontend/assets/js/main.js
|
||||||
|
|
||||||
|
## Expected Files to Modify
|
||||||
|
- uno o más archivos nuevos de frontend para esta vista, por ejemplo:
|
||||||
|
- frontend/historico.html
|
||||||
|
- frontend/assets/js/historico.js
|
||||||
|
- frontend/assets/css/historico.css
|
||||||
|
- opcionalmente frontend/index.html si se añade un enlace de acceso razonable a la nueva vista
|
||||||
|
- opcionalmente documentación mínima si hay que reflejar la nueva pantalla propia
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
- No usar páginas de la comunidad como UI del producto.
|
||||||
|
- No incrustar ni duplicar HTML externo.
|
||||||
|
- No romper la landing actual.
|
||||||
|
- No introducir frameworks nuevos.
|
||||||
|
- No hacer cambios destructivos.
|
||||||
|
- Mantener la solución centrada en una primera UI histórica útil y clara.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
- Existe una primera página histórica propia del proyecto.
|
||||||
|
- La página consume `GET /api/historical/weekly-top-kills`.
|
||||||
|
- El usuario puede alternar entre los 2 servidores.
|
||||||
|
- La UI muestra posiciones, jugadores y kills de forma clara.
|
||||||
|
- Existen estados de loading, empty y error.
|
||||||
|
- Los cambios quedan committeados y se hace push al remoto si el entorno lo permite.
|
||||||
|
|
||||||
|
## Change Budget
|
||||||
|
- Preferir menos de 6 archivos modificados o creados.
|
||||||
|
- Preferir menos de 260 líneas cambiadas.
|
||||||
|
## Outcome
|
||||||
|
- Se creó `frontend/historico.html` como primera UI histórica propia del proyecto.
|
||||||
|
- La vista consume `GET /api/historical/weekly-top-kills` desde `frontend/assets/js/historico.js`.
|
||||||
|
- Se añadieron estados de carga, vacÃo, error y mensaje de datos históricos insuficientes.
|
||||||
|
- El selector permite alternar entre `comunidad-hispana-01` y `comunidad-hispana-02`.
|
||||||
|
|
||||||
|
## Validation Notes
|
||||||
|
- `python -m compileall app`
|
||||||
|
- comprobación local de payload con `build_weekly_top_kills_payload(limit=3, server_id='comunidad-hispana-01')`
|
||||||
|
- `node --check frontend/assets/js/historico.js`
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# TASK-034-historical-refresh-automation-and-ops
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Automatizar y documentar el refresco periódico del histórico CRCON para ambos servidores de la comunidad, dejando el sistema preparado para mantenerse actualizado sin intervención manual constante.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
Ya existe ingesta bootstrap, refresco incremental y validación de calidad histórica. El siguiente paso técnico es estabilizar la operativa del histórico con un flujo repetible y documentado que permita mantener actualizados los datos históricos de forma consistente.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
1. Revisar la ingesta histórica actual y el refresco incremental ya implementado.
|
||||||
|
2. Diseñar la estrategia operativa de refresco histórico:
|
||||||
|
- frecuencia recomendada
|
||||||
|
- modo de ejecución local
|
||||||
|
- modo de ejecución automatizada
|
||||||
|
- control de errores y reintentos básicos
|
||||||
|
3. Implementar un mecanismo razonable para lanzar el refresco histórico periódicamente o dejarlo listo para ello.
|
||||||
|
4. Registrar metadatos mínimos de operación:
|
||||||
|
- último refresco ejecutado
|
||||||
|
- resultado
|
||||||
|
- errores básicos si ocurren
|
||||||
|
5. Documentar claramente cómo ejecutar y mantener esta capa histórica.
|
||||||
|
6. No crear todavía dashboards de operaciones complejos.
|
||||||
|
7. No romper el flujo actual de live status.
|
||||||
|
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
|
||||||
|
- docs/historical-domain-model.md
|
||||||
|
- docs/historical-data-quality-notes.md
|
||||||
|
|
||||||
|
## Expected Files to Modify
|
||||||
|
- backend/README.md
|
||||||
|
- backend/app/config.py
|
||||||
|
- backend/app/historical_ingestion.py
|
||||||
|
- opcionalmente nuevos módulos/entrypoints si mejoran la automatización, por ejemplo:
|
||||||
|
- backend/app/historical_jobs.py
|
||||||
|
- backend/app/historical_runner.py
|
||||||
|
- opcionalmente documentación técnica adicional
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
- No crear UI histórica en esta task.
|
||||||
|
- No romper la ingesta actual.
|
||||||
|
- No introducir complejidad operativa excesiva.
|
||||||
|
- No hacer cambios destructivos.
|
||||||
|
- Mantener el trabajo centrado en automatización y operativa del histórico.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
- Existe una estrategia clara y ejecutable de refresco periódico histórico.
|
||||||
|
- La documentación explica cómo ejecutar el refresco.
|
||||||
|
- La capa histórica queda operativamente más estable.
|
||||||
|
- Los cambios quedan committeados y se hace push al remoto si el entorno lo permite.
|
||||||
|
|
||||||
|
## Change Budget
|
||||||
|
- Preferir menos de 5 archivos modificados o creados.
|
||||||
|
- Preferir menos de 220 líneas cambiadas.
|
||||||
|
## Outcome
|
||||||
|
- Se añadió `backend/app/historical_runner.py` para ejecutar refresh incremental periódico.
|
||||||
|
- Se incorporaron variables de configuración para intervalo, reintentos y espera entre reintentos.
|
||||||
|
- La operativa queda documentada en `backend/README.md`.
|
||||||
|
- El registro operativo sigue apoyándose en `historical_ingestion_runs` para dejar último refresh, resultado y errores básicos.
|
||||||
|
|
||||||
|
## Validation Notes
|
||||||
|
- `python -m compileall app`
|
||||||
|
- validación estática del runner y de los getters de configuración
|
||||||
|
- no se ejecutó refresh real contra red externa por las restricciones del entorno actual
|
||||||
68
ai/tasks/done/TASK-035-historical-recent-matches-api.md
Normal file
68
ai/tasks/done/TASK-035-historical-recent-matches-api.md
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# TASK-035-historical-recent-matches-api
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Exponer una API histórica para consultar partidas recientes por servidor usando el histórico persistido propio del proyecto.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
Tras tener almacenamiento histórico y validación de datos, la siguiente métrica útil además del ranking semanal es la consulta de partidas recientes. Esto permitirá construir después vistas más completas del histórico sin depender de la web externa de la comunidad.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
1. Revisar el modelo histórico persistido y los datos realmente disponibles por partida.
|
||||||
|
2. Diseñar un endpoint o conjunto mínimo de endpoints para devolver partidas recientes por servidor.
|
||||||
|
3. Incluir en el payload, como mínimo:
|
||||||
|
- servidor
|
||||||
|
- match_id o identificador interno/externo útil
|
||||||
|
- fecha/hora
|
||||||
|
- mapa
|
||||||
|
- resultado o metadato de cierre si está disponible
|
||||||
|
- total de jugadores o metadatos útiles si existen
|
||||||
|
4. Definir orden, límites y paginación básica si encaja con la fase actual.
|
||||||
|
5. Documentar el endpoint en backend.
|
||||||
|
6. No crear todavía UI nueva en esta task.
|
||||||
|
7. Al completar la implementación:
|
||||||
|
- dejar el repositorio consistente
|
||||||
|
- hacer commit
|
||||||
|
- hacer push al remoto si el entorno lo permite
|
||||||
|
|
||||||
|
## Files to Read First
|
||||||
|
- AGENTS.md
|
||||||
|
- backend/README.md
|
||||||
|
- backend/app/routes.py
|
||||||
|
- backend/app/payloads.py
|
||||||
|
- backend/app/historical_storage.py
|
||||||
|
- backend/app/historical_models.py
|
||||||
|
- docs/historical-domain-model.md
|
||||||
|
|
||||||
|
## Expected Files to Modify
|
||||||
|
- backend/app/routes.py
|
||||||
|
- backend/app/payloads.py
|
||||||
|
- backend/README.md
|
||||||
|
- opcionalmente nuevos módulos de query histórica, por ejemplo:
|
||||||
|
- backend/app/historical_queries.py
|
||||||
|
- backend/app/historical_payloads.py
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
- No usar A2S para esta API.
|
||||||
|
- No crear UI en esta task.
|
||||||
|
- No depender del HTML de la comunidad.
|
||||||
|
- No hacer cambios destructivos.
|
||||||
|
- Mantener el trabajo centrado en una API histórica propia y clara.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
- Existe un endpoint usable de partidas recientes por servidor.
|
||||||
|
- El payload es claro y reutilizable.
|
||||||
|
- No se rompe la capa histórica existente.
|
||||||
|
- Los cambios quedan committeados y se hace push al remoto si el entorno lo permite.
|
||||||
|
|
||||||
|
## Change Budget
|
||||||
|
- Preferir menos de 5 archivos modificados o creados.
|
||||||
|
- Preferir menos de 220 líneas cambiadas.
|
||||||
|
## Outcome
|
||||||
|
- Se expuso `GET /api/historical/recent-matches`.
|
||||||
|
- El payload devuelve servidor, `match_id`, cierre temporal, mapa, marcador, ganador y conteo de jugadores.
|
||||||
|
- La consulta se apoya solo en la persistencia histórica propia (`historical_*`).
|
||||||
|
- La documentación del endpoint quedó actualizada en `backend/README.md`.
|
||||||
|
|
||||||
|
## Validation Notes
|
||||||
|
- `python -m compileall app`
|
||||||
|
- comprobación local de payload con `build_recent_historical_matches_payload(limit=3, server_slug='comunidad-hispana-01')`
|
||||||
67
ai/tasks/done/TASK-036-historical-recent-matches-ui.md
Normal file
67
ai/tasks/done/TASK-036-historical-recent-matches-ui.md
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# TASK-036-historical-recent-matches-ui
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Añadir a la UI histórica propia del proyecto una vista o bloque de partidas recientes por servidor, consumiendo exclusivamente la API interna del backend.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
La primera UI histórica del proyecto debe crecer de forma progresiva. Tras el ranking semanal, el siguiente bloque lógico es una vista de partidas recientes que complemente el histórico y aporte más contexto al usuario.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
1. Revisar la UI histórica ya implementada o diseñada en la task previa.
|
||||||
|
2. Revisar el endpoint de partidas recientes y su payload.
|
||||||
|
3. Añadir una sección o vista clara para mostrar partidas recientes por servidor.
|
||||||
|
4. Mostrar, como mínimo:
|
||||||
|
- fecha/hora
|
||||||
|
- mapa
|
||||||
|
- servidor
|
||||||
|
- metadatos relevantes si están disponibles
|
||||||
|
5. Mantener la navegación y el selector de servidor claros.
|
||||||
|
6. Añadir estados de loading, empty y error para esta vista.
|
||||||
|
7. Mantener coherencia visual con el resto del frontend histórico.
|
||||||
|
8. No usar páginas de la comunidad ni incrustaciones externas.
|
||||||
|
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
|
||||||
|
- docs/historical-domain-model.md
|
||||||
|
|
||||||
|
## Expected Files to Modify
|
||||||
|
- frontend/historico.html
|
||||||
|
- frontend/assets/js/historico.js
|
||||||
|
- frontend/assets/css/historico.css
|
||||||
|
- opcionalmente frontend/index.html si se mejora el acceso a la zona histórica
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
- No usar UI externa de la comunidad.
|
||||||
|
- No introducir frameworks nuevos.
|
||||||
|
- No romper la landing actual.
|
||||||
|
- No hacer cambios destructivos.
|
||||||
|
- Mantener el trabajo centrado en ampliar la UI histórica propia.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
- La UI histórica muestra partidas recientes por servidor.
|
||||||
|
- La UI consume exclusivamente la API interna.
|
||||||
|
- Existen estados de carga, vacío y error.
|
||||||
|
- La interfaz sigue siendo coherente con el resto del proyecto.
|
||||||
|
- Los cambios quedan committeados y se hace push al remoto si el entorno lo permite.
|
||||||
|
|
||||||
|
## Change Budget
|
||||||
|
- Preferir menos de 5 archivos modificados o creados.
|
||||||
|
- Preferir menos de 240 líneas cambiadas.
|
||||||
|
## Outcome
|
||||||
|
- La UI histórica propia ahora muestra un bloque de partidas recientes por servidor.
|
||||||
|
- El bloque usa exclusivamente `GET /api/historical/recent-matches`.
|
||||||
|
- El selector de servidor se comparte con el ranking semanal para mantener consistencia.
|
||||||
|
- Se cubrieron estados de carga, vacÃo y error para esta vista.
|
||||||
|
|
||||||
|
## Validation Notes
|
||||||
|
- `node --check frontend/assets/js/historico.js`
|
||||||
|
- revisión manual de la estructura HTML/CSS para desktop y mobile
|
||||||
62
ai/tasks/done/TASK-037-historical-server-summary-api.md
Normal file
62
ai/tasks/done/TASK-037-historical-server-summary-api.md
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# TASK-037-historical-server-summary-api
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Exponer una API de resumen histórico por servidor que sirva como base para futuros bloques de overview y comparativas.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
Además de rankings y partidas recientes, el proyecto necesitará una capa resumida de datos por servidor para poder mostrar actividad agregada y métricas de alto nivel sin recalcularlas en el frontend.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
1. Revisar el histórico persistido y las métricas disponibles de forma fiable.
|
||||||
|
2. Diseñar un endpoint de resumen por servidor con métricas agregadas útiles. Incluir, si es viable:
|
||||||
|
- número de partidas históricas
|
||||||
|
- número de jugadores únicos
|
||||||
|
- kills agregadas
|
||||||
|
- mapas más jugados o conteo de mapas
|
||||||
|
- rango temporal cubierto
|
||||||
|
3. Implementar el endpoint de forma clara y reutilizable.
|
||||||
|
4. Documentar el endpoint en backend.
|
||||||
|
5. No crear UI nueva en esta task.
|
||||||
|
6. 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/routes.py
|
||||||
|
- backend/app/payloads.py
|
||||||
|
- backend/app/historical_storage.py
|
||||||
|
- backend/app/historical_models.py
|
||||||
|
- docs/historical-domain-model.md
|
||||||
|
|
||||||
|
## Expected Files to Modify
|
||||||
|
- backend/app/routes.py
|
||||||
|
- backend/app/payloads.py
|
||||||
|
- backend/README.md
|
||||||
|
- opcionalmente nuevos módulos de query histórica
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
- No usar A2S para resumen histórico.
|
||||||
|
- No crear UI en esta task.
|
||||||
|
- No hacer cambios destructivos.
|
||||||
|
- Mantener el trabajo centrado en una API agregada estable.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
- Existe un endpoint de resumen histórico por servidor.
|
||||||
|
- El payload es claro y reutilizable.
|
||||||
|
- El endpoint se basa en el histórico propio persistido.
|
||||||
|
- Los cambios quedan committeados y se hace push al remoto si el entorno lo permite.
|
||||||
|
|
||||||
|
## Change Budget
|
||||||
|
- Preferir menos de 5 archivos modificados o creados.
|
||||||
|
- Preferir menos de 220 líneas cambiadas.
|
||||||
|
## Outcome
|
||||||
|
- Se expuso `GET /api/historical/server-summary`.
|
||||||
|
- El endpoint agrega partidas históricas, jugadores únicos, kills, conteo de mapas, mapas dominantes y rango temporal cubierto.
|
||||||
|
- La consulta se resuelve desde la persistencia histórica propia y queda documentada en `backend/README.md`.
|
||||||
|
|
||||||
|
## Validation Notes
|
||||||
|
- `python -m compileall app`
|
||||||
|
- comprobación local de payload con `build_historical_server_summary_payload(server_slug='comunidad-hispana-01')`
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# TASK-038-historical-player-profile-api-foundation
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Crear la base de API para perfiles históricos de jugador dentro del proyecto, apoyándose en el histórico propio ya persistido y preparando futuras vistas de detalle sin depender de páginas externas.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
Una vez resueltos rankings y partidas, el siguiente paso natural del histórico es poder profundizar por jugador. Esta task no debe construir todavía la UI completa de perfil, pero sí dejar lista una base de consulta que permita en fases posteriores mostrar el recorrido histórico de un jugador en los servidores de la comunidad.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
1. Revisar la estructura actual de identidad de jugador validada en la task de calidad histórica.
|
||||||
|
2. Diseñar una base de endpoint o endpoints para consultar un jugador histórico.
|
||||||
|
3. Incluir, como mínimo, datos como:
|
||||||
|
- identidad de jugador
|
||||||
|
- servidor o servidores donde aparece
|
||||||
|
- kills agregadas
|
||||||
|
- número de partidas
|
||||||
|
- rango temporal cubierto
|
||||||
|
4. Implementar la consulta de forma consistente con la estructura histórica existente.
|
||||||
|
5. Documentar el endpoint en backend.
|
||||||
|
6. No crear todavía UI final de perfil.
|
||||||
|
7. Al completar la implementación:
|
||||||
|
- dejar el repositorio consistente
|
||||||
|
- hacer commit
|
||||||
|
- hacer push al remoto si el entorno lo permite
|
||||||
|
|
||||||
|
## Files to Read First
|
||||||
|
- AGENTS.md
|
||||||
|
- backend/README.md
|
||||||
|
- backend/app/historical_storage.py
|
||||||
|
- backend/app/historical_models.py
|
||||||
|
- backend/app/routes.py
|
||||||
|
- backend/app/payloads.py
|
||||||
|
- docs/historical-data-quality-notes.md
|
||||||
|
|
||||||
|
## Expected Files to Modify
|
||||||
|
- backend/app/routes.py
|
||||||
|
- backend/app/payloads.py
|
||||||
|
- backend/README.md
|
||||||
|
- opcionalmente nuevos módulos de query histórica por jugador
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
- No usar páginas externas como perfil del producto.
|
||||||
|
- No crear UI final de perfil todavía.
|
||||||
|
- No romper endpoints históricos existentes.
|
||||||
|
- No hacer cambios destructivos.
|
||||||
|
- Mantener el trabajo centrado en la base de API de perfil.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
- Existe una base de endpoint para perfil histórico de jugador.
|
||||||
|
- El endpoint se apoya en la persistencia histórica propia.
|
||||||
|
- La identidad de jugador usada es consistente con la corrección de calidad ya realizada.
|
||||||
|
- Los cambios quedan committeados y se hace push al remoto si el entorno lo permite.
|
||||||
|
|
||||||
|
## Change Budget
|
||||||
|
- Preferir menos de 5 archivos modificados o creados.
|
||||||
|
- Preferir menos de 220 líneas cambiadas.
|
||||||
|
## Outcome
|
||||||
|
- Se expuso `GET /api/historical/player-profile?player=...`.
|
||||||
|
- El endpoint acepta `stable_player_key`, `steam_id` o `source_player_id`.
|
||||||
|
- La respuesta devuelve identidad de jugador, kills agregadas, número de partidas, rango temporal y desglose por servidores.
|
||||||
|
- No se construyó UI final de perfil en esta task.
|
||||||
|
|
||||||
|
## Validation Notes
|
||||||
|
- `python -m compileall app`
|
||||||
|
- comprobación local de payload reutilizando un `stable_player_key` real del ranking semanal
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# TASK-039-historical-navigation-and-entry-points
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Añadir puntos de entrada y navegación razonables hacia la zona histórica propia del proyecto sin romper la simplicidad actual de la landing.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
Si el proyecto empieza a tener una UI histórica propia, hace falta que el usuario pueda llegar a ella con una navegación clara. Esta task debe resolver ese acceso sin convertir la landing en una aplicación compleja ni depender de enlaces a páginas externas de la comunidad.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
1. Revisar la landing actual y la UI histórica creada.
|
||||||
|
2. Diseñar uno o más puntos de entrada razonables hacia la zona histórica propia.
|
||||||
|
3. Añadir enlaces o CTAs discretos y coherentes para acceder al histórico.
|
||||||
|
4. Mantener la simplicidad de la landing.
|
||||||
|
5. No sustituir los botones `Histórico` actuales del panel de servidores si siguen teniendo sentido como acceso al scoreboard externo; solo añadir navegación propia del proyecto donde aporte valor.
|
||||||
|
6. 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/index.html
|
||||||
|
- frontend/assets/css/styles.css
|
||||||
|
- frontend/assets/js/main.js
|
||||||
|
- frontend/historico.html
|
||||||
|
- frontend/assets/css/historico.css
|
||||||
|
- frontend/assets/js/historico.js
|
||||||
|
|
||||||
|
## Expected Files to Modify
|
||||||
|
- frontend/index.html
|
||||||
|
- frontend/assets/css/styles.css
|
||||||
|
- opcionalmente frontend/assets/js/main.js
|
||||||
|
- opcionalmente la propia UI histórica si requiere ajustes de navegación
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
- No recargar la landing con demasiada navegación.
|
||||||
|
- No romper la landing actual.
|
||||||
|
- No depender de URLs de la comunidad como navegación principal del producto.
|
||||||
|
- No hacer cambios destructivos.
|
||||||
|
- Mantener el trabajo centrado en accesos claros y ligeros.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
- Existe una forma clara de acceder a la zona histórica propia.
|
||||||
|
- La landing mantiene su simplicidad.
|
||||||
|
- No se rompe el flujo actual de usuario.
|
||||||
|
- Los cambios quedan committeados y se hace push al remoto si el entorno lo permite.
|
||||||
|
|
||||||
|
## Change Budget
|
||||||
|
- Preferir menos de 4 archivos modificados.
|
||||||
|
- Preferir menos de 160 líneas cambiadas.
|
||||||
|
## Outcome
|
||||||
|
- Se añadieron CTAs discretos hacia `frontend/historico.html` en la hero y en el panel de servidores.
|
||||||
|
- Se añadió un enlace de vuelta desde la vista histórica a la landing.
|
||||||
|
- Se preservaron los botones actuales `Historico` hacia scoreboards externos en las cards de servidores.
|
||||||
|
|
||||||
|
## Validation Notes
|
||||||
|
- revisión de `frontend/index.html` y `frontend/assets/css/styles.css` para mantener simplicidad y coherencia visual
|
||||||
|
- `node --check frontend/assets/js/historico.js`
|
||||||
72
ai/tasks/done/TASK-040-historical-ui-qa-and-polish-pass.md
Normal file
72
ai/tasks/done/TASK-040-historical-ui-qa-and-polish-pass.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# TASK-040-historical-ui-qa-and-polish-pass
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Realizar una pasada final de QA y pulido sobre la nueva capa histórica del proyecto, tanto en backend como en la UI histórica propia, antes de abrir futuras métricas más avanzadas.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
Tras añadir varias capas históricas de API y UI, conviene consolidar la calidad del resultado antes de seguir creciendo. Esta task debe centrarse en detectar pequeños defectos de presentación, consistencia, navegación, payload o comportamiento.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
1. Revisar la UI histórica completa y los endpoints históricos ya expuestos.
|
||||||
|
2. Validar:
|
||||||
|
- weekly top kills
|
||||||
|
- partidas recientes
|
||||||
|
- resumen histórico si existe
|
||||||
|
- navegación histórica
|
||||||
|
3. Revisar:
|
||||||
|
- estados de loading/error/empty
|
||||||
|
- consistencia de servidor seleccionado
|
||||||
|
- consistencia de naming
|
||||||
|
- legibilidad de tablas/listados
|
||||||
|
- responsive básico
|
||||||
|
4. Corregir pequeños defectos o inconsistencias detectadas.
|
||||||
|
5. No abrir rediseños grandes en esta task.
|
||||||
|
6. 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/routes.py
|
||||||
|
- backend/app/payloads.py
|
||||||
|
- frontend/historico.html
|
||||||
|
- frontend/assets/js/historico.js
|
||||||
|
- frontend/assets/css/historico.css
|
||||||
|
- frontend/index.html
|
||||||
|
- frontend/assets/css/styles.css
|
||||||
|
|
||||||
|
## Expected Files to Modify
|
||||||
|
- frontend/historico.html
|
||||||
|
- frontend/assets/js/historico.js
|
||||||
|
- frontend/assets/css/historico.css
|
||||||
|
- backend/app/routes.py
|
||||||
|
- backend/app/payloads.py
|
||||||
|
- opcionalmente documentación mínima si alguna parte visible necesita quedar reflejada
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
- No abrir nuevas grandes features históricas en esta task.
|
||||||
|
- No depender de UI externa.
|
||||||
|
- No hacer cambios destructivos.
|
||||||
|
- Mantener el trabajo centrado en QA y acabado.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
- La capa histórica propia funciona con consistencia suficiente.
|
||||||
|
- La UI histórica está pulida y usable.
|
||||||
|
- No hay defectos relevantes en el flujo principal.
|
||||||
|
- Los cambios quedan committeados y se hace push al remoto si el entorno lo permite.
|
||||||
|
|
||||||
|
## Change Budget
|
||||||
|
- Preferir menos de 6 archivos modificados.
|
||||||
|
- Preferir menos de 220 líneas cambiadas.
|
||||||
|
## Outcome
|
||||||
|
- Se revisó la consistencia entre selector de servidor, resumen, ranking semanal y partidas recientes.
|
||||||
|
- Se ajustaron estados visibles de loading, error y vacÃo en la vista histórica.
|
||||||
|
- Se añadieron ajustes responsive básicos en `frontend/assets/css/historico.css`.
|
||||||
|
- No se abrieron features históricas grandes fuera del alcance de QA y acabado.
|
||||||
|
|
||||||
|
## Validation Notes
|
||||||
|
- `python -m compileall app`
|
||||||
|
- `node --check frontend/assets/js/historico.js`
|
||||||
|
- comprobación local de payloads históricos: weekly top kills, recent matches, server summary y player profile
|
||||||
@@ -26,6 +26,7 @@ backend/
|
|||||||
|-- main.py
|
|-- main.py
|
||||||
|-- historical_ingestion.py
|
|-- historical_ingestion.py
|
||||||
|-- historical_models.py
|
|-- historical_models.py
|
||||||
|
|-- historical_runner.py
|
||||||
|-- historical_storage.py
|
|-- historical_storage.py
|
||||||
|-- normalizers.py
|
|-- normalizers.py
|
||||||
|-- payloads.py
|
|-- payloads.py
|
||||||
@@ -65,6 +66,9 @@ Variables opcionales:
|
|||||||
- `HLL_BACKEND_REFRESH_INTERVAL_SECONDS`
|
- `HLL_BACKEND_REFRESH_INTERVAL_SECONDS`
|
||||||
- `HLL_HISTORICAL_CRCON_PAGE_SIZE`
|
- `HLL_HISTORICAL_CRCON_PAGE_SIZE`
|
||||||
- `HLL_HISTORICAL_CRCON_TIMEOUT_SECONDS`
|
- `HLL_HISTORICAL_CRCON_TIMEOUT_SECONDS`
|
||||||
|
- `HLL_HISTORICAL_REFRESH_INTERVAL_SECONDS`
|
||||||
|
- `HLL_HISTORICAL_REFRESH_MAX_RETRIES`
|
||||||
|
- `HLL_HISTORICAL_REFRESH_RETRY_DELAY_SECONDS`
|
||||||
|
|
||||||
El `frontend/index.html` viene preparado para volver a consultar el bloque de
|
El `frontend/index.html` viene preparado para volver a consultar el bloque de
|
||||||
servidores cada `120000` ms (`120s`) sin recargar la pagina completa. La landing
|
servidores cada `120000` ms (`120s`) sin recargar la pagina completa. La landing
|
||||||
@@ -115,6 +119,9 @@ normaliza espacios y barras finales para mantener la comparacion con el header
|
|||||||
- `GET /api/servers/history?limit=20`
|
- `GET /api/servers/history?limit=20`
|
||||||
- `GET /api/servers/{id}/history?limit=20`
|
- `GET /api/servers/{id}/history?limit=20`
|
||||||
- `GET /api/historical/weekly-top-kills?limit=10&server=comunidad-hispana-01`
|
- `GET /api/historical/weekly-top-kills?limit=10&server=comunidad-hispana-01`
|
||||||
|
- `GET /api/historical/recent-matches?limit=20&server=comunidad-hispana-01`
|
||||||
|
- `GET /api/historical/server-summary?server=comunidad-hispana-01`
|
||||||
|
- `GET /api/historical/player-profile?player=steam%3A76561198000000000`
|
||||||
|
|
||||||
`GET /api/servers` trata el ultimo snapshot persistido como cache local y lo
|
`GET /api/servers` trata el ultimo snapshot persistido como cache local y lo
|
||||||
reutiliza solo si sigue dentro del objetivo de `120` segundos. Si ese snapshot
|
reutiliza solo si sigue dentro del objetivo de `120` segundos. Si ese snapshot
|
||||||
@@ -153,6 +160,7 @@ colector. Si todavia no hay snapshots guardados, responden `status: "ok"` con
|
|||||||
- `config.py` centraliza host, puerto y allowlist minima de origenes locales.
|
- `config.py` centraliza host, puerto y allowlist minima de origenes locales.
|
||||||
- `historical_ingestion.py` consulta la capa JSON publica de CRCON para bootstrap y refresh incremental.
|
- `historical_ingestion.py` consulta la capa JSON publica de CRCON para bootstrap y refresh incremental.
|
||||||
- `historical_models.py` fija las entidades historicas minimas del dominio.
|
- `historical_models.py` fija las entidades historicas minimas del dominio.
|
||||||
|
- `historical_runner.py` ejecuta refresh incremental periodico con reintentos basicos.
|
||||||
- `historical_storage.py` prepara la persistencia `historical_*` y las consultas agregadas iniciales.
|
- `historical_storage.py` prepara la persistencia `historical_*` y las consultas agregadas iniciales.
|
||||||
- `main.py` contiene el entrypoint HTTP y la creacion del servidor.
|
- `main.py` contiene el entrypoint HTTP y la creacion del servidor.
|
||||||
- `normalizers.py` transforma registros crudos o respuestas A2S a un modelo
|
- `normalizers.py` transforma registros crudos o respuestas A2S a un modelo
|
||||||
@@ -413,14 +421,19 @@ consulta historica:
|
|||||||
persistido por el colector. El parametro opcional `limit` acepta valores entre
|
persistido por el colector. El parametro opcional `limit` acepta valores entre
|
||||||
`1` y `100`.
|
`1` y `100`.
|
||||||
|
|
||||||
La primera consulta agregada sobre historico CRCON es:
|
La capa historica propia expone:
|
||||||
|
|
||||||
- `/api/historical/weekly-top-kills`
|
- `/api/historical/weekly-top-kills`
|
||||||
|
- `/api/historical/recent-matches`
|
||||||
|
- `/api/historical/server-summary`
|
||||||
|
- `/api/historical/player-profile`
|
||||||
|
|
||||||
Parametros opcionales:
|
Parametros opcionales:
|
||||||
|
|
||||||
- `limit` entre `1` y `100`
|
- `limit` entre `1` y `100`
|
||||||
- `server` con slug historico como `comunidad-hispana-01`
|
- `server` con slug historico como `comunidad-hispana-01`
|
||||||
|
- `player` en `/api/historical/player-profile` aceptando `stable_player_key`,
|
||||||
|
`steam_id` o `source_player_id`
|
||||||
|
|
||||||
La ventana temporal es siempre la ultima semana movil respecto al momento de la
|
La ventana temporal es siempre la ultima semana movil respecto al momento de la
|
||||||
request y solo considera partidas cerradas con `ended_at` para no mezclar
|
request y solo considera partidas cerradas con `ended_at` para no mezclar
|
||||||
@@ -428,6 +441,11 @@ partidas aun en curso ni filas historicas transitorias. El payload devuelve
|
|||||||
servidor, rango temporal, jugador, kills semanales, posicion y numero de
|
servidor, rango temporal, jugador, kills semanales, posicion y numero de
|
||||||
partidas consideradas.
|
partidas consideradas.
|
||||||
|
|
||||||
|
`recent-matches` devuelve cierres recientes por servidor con marcador, mapa y
|
||||||
|
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.
|
||||||
|
|
||||||
## Ingesta historica CRCON
|
## Ingesta historica CRCON
|
||||||
|
|
||||||
La ingesta historica no usa A2S ni scraping del HTML de `/games`. Consume la
|
La ingesta historica no usa A2S ni scraping del HTML de `/games`. Consume la
|
||||||
@@ -444,6 +462,7 @@ Comandos manuales desde `backend/`:
|
|||||||
```powershell
|
```powershell
|
||||||
python -m app.historical_ingestion bootstrap
|
python -m app.historical_ingestion bootstrap
|
||||||
python -m app.historical_ingestion refresh
|
python -m app.historical_ingestion refresh
|
||||||
|
python -m app.historical_runner --interval 1800
|
||||||
```
|
```
|
||||||
|
|
||||||
Flags utiles:
|
Flags utiles:
|
||||||
@@ -457,6 +476,24 @@ La ejecucion `refresh` usa una ventana de solape sobre la ultima partida
|
|||||||
persistida por servidor para releer solo paginas recientes y absorber updates
|
persistida por servidor para releer solo paginas recientes y absorber updates
|
||||||
tardios sin reimportar todo el historico.
|
tardios sin reimportar todo el historico.
|
||||||
|
|
||||||
|
El runner `python -m app.historical_runner` deja ese refresh incremental listo
|
||||||
|
para ejecucion local repetida sin depender de infraestructura externa. Por
|
||||||
|
defecto:
|
||||||
|
|
||||||
|
- refresca cada `1800` segundos
|
||||||
|
- reintenta hasta `2` veces tras un fallo
|
||||||
|
- espera `30` segundos entre reintentos
|
||||||
|
- reutiliza el registro de `historical_ingestion_runs` para dejar trazabilidad
|
||||||
|
de ultimo refresh, resultado y errores basicos
|
||||||
|
|
||||||
|
Flags utiles del runner:
|
||||||
|
|
||||||
|
- `--server comunidad-hispana-01` para limitar a un servidor
|
||||||
|
- `--interval 900` para aumentar frecuencia local
|
||||||
|
- `--retries 1` para reducir reintentos
|
||||||
|
- `--retry-delay 10` para bajar la espera entre fallos
|
||||||
|
- `--max-runs 1` para una validacion puntual sin bucle indefinido
|
||||||
|
|
||||||
Al inicializar la persistencia local, el backend normaliza tambien la identidad
|
Al inicializar la persistencia local, el backend normaliza tambien la identidad
|
||||||
historica ya guardada:
|
historica ya guardada:
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ DEFAULT_STORAGE_FILENAME = "hll_vietnam_dev.sqlite3"
|
|||||||
DEFAULT_REFRESH_INTERVAL_SECONDS = 120
|
DEFAULT_REFRESH_INTERVAL_SECONDS = 120
|
||||||
DEFAULT_HISTORICAL_CRCON_PAGE_SIZE = 50
|
DEFAULT_HISTORICAL_CRCON_PAGE_SIZE = 50
|
||||||
DEFAULT_HISTORICAL_CRCON_TIMEOUT_SECONDS = 15.0
|
DEFAULT_HISTORICAL_CRCON_TIMEOUT_SECONDS = 15.0
|
||||||
|
DEFAULT_HISTORICAL_REFRESH_INTERVAL_SECONDS = 1800
|
||||||
|
DEFAULT_HISTORICAL_REFRESH_MAX_RETRIES = 2
|
||||||
|
DEFAULT_HISTORICAL_REFRESH_RETRY_DELAY_SECONDS = 30
|
||||||
DEFAULT_ALLOWED_ORIGINS = (
|
DEFAULT_ALLOWED_ORIGINS = (
|
||||||
"null",
|
"null",
|
||||||
"http://127.0.0.1:5500",
|
"http://127.0.0.1:5500",
|
||||||
@@ -95,6 +98,47 @@ def get_historical_crcon_request_timeout_seconds() -> float:
|
|||||||
return timeout_seconds
|
return timeout_seconds
|
||||||
|
|
||||||
|
|
||||||
|
def get_historical_refresh_interval_seconds() -> int:
|
||||||
|
"""Return the default interval used by the historical refresh loop."""
|
||||||
|
configured_value = os.getenv(
|
||||||
|
"HLL_HISTORICAL_REFRESH_INTERVAL_SECONDS",
|
||||||
|
str(DEFAULT_HISTORICAL_REFRESH_INTERVAL_SECONDS),
|
||||||
|
)
|
||||||
|
interval_seconds = int(configured_value)
|
||||||
|
if interval_seconds <= 0:
|
||||||
|
raise ValueError("HLL_HISTORICAL_REFRESH_INTERVAL_SECONDS must be positive.")
|
||||||
|
|
||||||
|
return interval_seconds
|
||||||
|
|
||||||
|
|
||||||
|
def get_historical_refresh_max_retries() -> int:
|
||||||
|
"""Return the retry count used by the historical refresh loop."""
|
||||||
|
configured_value = os.getenv(
|
||||||
|
"HLL_HISTORICAL_REFRESH_MAX_RETRIES",
|
||||||
|
str(DEFAULT_HISTORICAL_REFRESH_MAX_RETRIES),
|
||||||
|
)
|
||||||
|
max_retries = int(configured_value)
|
||||||
|
if max_retries < 0:
|
||||||
|
raise ValueError("HLL_HISTORICAL_REFRESH_MAX_RETRIES must be zero or positive.")
|
||||||
|
|
||||||
|
return max_retries
|
||||||
|
|
||||||
|
|
||||||
|
def get_historical_refresh_retry_delay_seconds() -> int:
|
||||||
|
"""Return the wait time between historical refresh retries."""
|
||||||
|
configured_value = os.getenv(
|
||||||
|
"HLL_HISTORICAL_REFRESH_RETRY_DELAY_SECONDS",
|
||||||
|
str(DEFAULT_HISTORICAL_REFRESH_RETRY_DELAY_SECONDS),
|
||||||
|
)
|
||||||
|
retry_delay_seconds = int(configured_value)
|
||||||
|
if retry_delay_seconds < 0:
|
||||||
|
raise ValueError(
|
||||||
|
"HLL_HISTORICAL_REFRESH_RETRY_DELAY_SECONDS must be zero or positive."
|
||||||
|
)
|
||||||
|
|
||||||
|
return retry_delay_seconds
|
||||||
|
|
||||||
|
|
||||||
def get_a2s_targets_payload() -> str | None:
|
def get_a2s_targets_payload() -> str | None:
|
||||||
"""Return the optional JSON payload that overrides local A2S targets."""
|
"""Return the optional JSON payload that overrides local A2S targets."""
|
||||||
raw_payload = os.getenv(DEFAULT_A2S_TARGETS_ENV_VAR)
|
raw_payload = os.getenv(DEFAULT_A2S_TARGETS_ENV_VAR)
|
||||||
|
|||||||
160
backend/app/historical_runner.py
Normal file
160
backend/app/historical_runner.py
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
"""Local development loop for periodic historical CRCON refreshes."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .config import (
|
||||||
|
get_historical_refresh_interval_seconds,
|
||||||
|
get_historical_refresh_max_retries,
|
||||||
|
get_historical_refresh_retry_delay_seconds,
|
||||||
|
)
|
||||||
|
from .historical_ingestion import run_incremental_refresh
|
||||||
|
|
||||||
|
|
||||||
|
def run_periodic_historical_refresh(
|
||||||
|
*,
|
||||||
|
interval_seconds: int,
|
||||||
|
max_retries: int,
|
||||||
|
retry_delay_seconds: int,
|
||||||
|
server_slug: str | None = None,
|
||||||
|
max_pages: int | None = None,
|
||||||
|
page_size: int | None = None,
|
||||||
|
max_runs: int | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Run periodic historical refreshes until interrupted or the limit is reached."""
|
||||||
|
completed_runs = 0
|
||||||
|
print(
|
||||||
|
"Starting historical refresh loop "
|
||||||
|
f"(interval={interval_seconds}s, retries={max_retries}, server={server_slug or 'all'})."
|
||||||
|
)
|
||||||
|
print("Press Ctrl+C to stop.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
while max_runs is None or completed_runs < max_runs:
|
||||||
|
completed_runs += 1
|
||||||
|
payload = _run_refresh_with_retries(
|
||||||
|
max_retries=max_retries,
|
||||||
|
retry_delay_seconds=retry_delay_seconds,
|
||||||
|
server_slug=server_slug,
|
||||||
|
max_pages=max_pages,
|
||||||
|
page_size=page_size,
|
||||||
|
)
|
||||||
|
print(json.dumps({"run": completed_runs, **payload}, indent=2))
|
||||||
|
|
||||||
|
if max_runs is not None and completed_runs >= max_runs:
|
||||||
|
break
|
||||||
|
|
||||||
|
time.sleep(interval_seconds)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\nHistorical refresh loop stopped by user.")
|
||||||
|
|
||||||
|
|
||||||
|
def _run_refresh_with_retries(
|
||||||
|
*,
|
||||||
|
max_retries: int,
|
||||||
|
retry_delay_seconds: int,
|
||||||
|
server_slug: str | None,
|
||||||
|
max_pages: int | None,
|
||||||
|
page_size: int | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
attempt = 0
|
||||||
|
while True:
|
||||||
|
attempt += 1
|
||||||
|
try:
|
||||||
|
result = run_incremental_refresh(
|
||||||
|
server_slug=server_slug,
|
||||||
|
max_pages=max_pages,
|
||||||
|
page_size=page_size,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"attempts_used": attempt,
|
||||||
|
"max_retries": max_retries,
|
||||||
|
"result": result,
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
if attempt > max_retries:
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"attempts_used": attempt,
|
||||||
|
"max_retries": max_retries,
|
||||||
|
"error": str(exc),
|
||||||
|
}
|
||||||
|
if retry_delay_seconds > 0:
|
||||||
|
time.sleep(retry_delay_seconds)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""Allow local scheduled historical refresh execution without external infra."""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Run periodic historical CRCON refreshes for HLL Vietnam.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--interval",
|
||||||
|
type=int,
|
||||||
|
default=get_historical_refresh_interval_seconds(),
|
||||||
|
help="Seconds to wait between incremental refresh runs.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--retries",
|
||||||
|
type=int,
|
||||||
|
default=get_historical_refresh_max_retries(),
|
||||||
|
help="Retry attempts after a failed incremental refresh.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--retry-delay",
|
||||||
|
type=int,
|
||||||
|
default=get_historical_refresh_retry_delay_seconds(),
|
||||||
|
help="Seconds to wait between failed attempts.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--server",
|
||||||
|
dest="server_slug",
|
||||||
|
help="Optional historical server slug.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--max-pages",
|
||||||
|
type=int,
|
||||||
|
default=None,
|
||||||
|
help="Optional page cap for local validation.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--page-size",
|
||||||
|
type=int,
|
||||||
|
default=None,
|
||||||
|
help="Optional override for CRCON page size.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--max-runs",
|
||||||
|
type=int,
|
||||||
|
default=None,
|
||||||
|
help="Optional safety limit for the number of refresh cycles to execute.",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.interval <= 0:
|
||||||
|
raise ValueError("--interval must be a positive integer.")
|
||||||
|
if args.retries < 0:
|
||||||
|
raise ValueError("--retries must be zero or positive.")
|
||||||
|
if args.retry_delay < 0:
|
||||||
|
raise ValueError("--retry-delay must be zero or positive.")
|
||||||
|
if args.max_runs is not None and args.max_runs <= 0:
|
||||||
|
raise ValueError("--max-runs must be positive when provided.")
|
||||||
|
|
||||||
|
run_periodic_historical_refresh(
|
||||||
|
interval_seconds=args.interval,
|
||||||
|
max_retries=args.retries,
|
||||||
|
retry_delay_seconds=args.retry_delay,
|
||||||
|
server_slug=args.server_slug,
|
||||||
|
max_pages=args.max_pages,
|
||||||
|
page_size=args.page_size,
|
||||||
|
max_runs=args.max_runs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -439,7 +439,7 @@ def list_recent_historical_matches(
|
|||||||
limit: int = 20,
|
limit: int = 20,
|
||||||
db_path: Path | None = None,
|
db_path: Path | None = None,
|
||||||
) -> list[dict[str, object]]:
|
) -> list[dict[str, object]]:
|
||||||
"""Return recent persisted matches for validation and later API work."""
|
"""Return recent persisted matches grouped for the historical API layer."""
|
||||||
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] = []
|
||||||
@@ -474,7 +474,217 @@ def list_recent_historical_matches(
|
|||||||
""",
|
""",
|
||||||
params,
|
params,
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return [dict(row) for row in rows]
|
items: list[dict[str, object]] = []
|
||||||
|
for row in rows:
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"server": {
|
||||||
|
"slug": row["server_slug"],
|
||||||
|
"name": row["server_name"],
|
||||||
|
},
|
||||||
|
"match_id": row["external_match_id"],
|
||||||
|
"started_at": row["started_at"],
|
||||||
|
"ended_at": row["ended_at"],
|
||||||
|
"closed_at": row["ended_at"] or row["started_at"],
|
||||||
|
"map": {
|
||||||
|
"name": row["map_name"],
|
||||||
|
"pretty_name": row["map_pretty_name"] or row["map_name"],
|
||||||
|
},
|
||||||
|
"result": {
|
||||||
|
"allied_score": _coerce_int(row["allied_score"]),
|
||||||
|
"axis_score": _coerce_int(row["axis_score"]),
|
||||||
|
"winner": _resolve_match_winner(
|
||||||
|
row["allied_score"],
|
||||||
|
row["axis_score"],
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"player_count": int(row["player_count"] or 0),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def list_historical_server_summaries(
|
||||||
|
*,
|
||||||
|
server_slug: str | None = None,
|
||||||
|
db_path: Path | None = None,
|
||||||
|
) -> list[dict[str, object]]:
|
||||||
|
"""Return aggregate historical metrics per server."""
|
||||||
|
resolved_path = initialize_historical_storage(db_path=db_path)
|
||||||
|
where_clause = ""
|
||||||
|
params: list[object] = []
|
||||||
|
if server_slug:
|
||||||
|
where_clause = "WHERE historical_servers.slug = ?"
|
||||||
|
params.append(server_slug)
|
||||||
|
|
||||||
|
with _connect(resolved_path) as connection:
|
||||||
|
summary_rows = connection.execute(
|
||||||
|
f"""
|
||||||
|
SELECT
|
||||||
|
historical_servers.slug AS server_slug,
|
||||||
|
historical_servers.display_name AS server_name,
|
||||||
|
COUNT(DISTINCT historical_matches.id) AS matches_count,
|
||||||
|
COUNT(DISTINCT historical_players.id) AS unique_players,
|
||||||
|
COALESCE(SUM(historical_player_match_stats.kills), 0) AS total_kills,
|
||||||
|
COUNT(DISTINCT COALESCE(historical_matches.map_pretty_name, historical_matches.map_name)) AS map_count,
|
||||||
|
MIN(COALESCE(historical_matches.ended_at, historical_matches.started_at, historical_matches.created_at_source)) AS first_match_at,
|
||||||
|
MAX(COALESCE(historical_matches.ended_at, historical_matches.started_at, historical_matches.created_at_source)) AS last_match_at
|
||||||
|
FROM historical_servers
|
||||||
|
LEFT JOIN historical_matches
|
||||||
|
ON historical_matches.historical_server_id = historical_servers.id
|
||||||
|
LEFT JOIN historical_player_match_stats
|
||||||
|
ON historical_player_match_stats.historical_match_id = historical_matches.id
|
||||||
|
LEFT JOIN historical_players
|
||||||
|
ON historical_players.id = historical_player_match_stats.historical_player_id
|
||||||
|
{where_clause}
|
||||||
|
GROUP BY historical_servers.id
|
||||||
|
ORDER BY historical_servers.server_number ASC, historical_servers.slug ASC
|
||||||
|
""",
|
||||||
|
params,
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
map_rows = connection.execute(
|
||||||
|
f"""
|
||||||
|
SELECT
|
||||||
|
historical_servers.slug AS server_slug,
|
||||||
|
COALESCE(historical_matches.map_pretty_name, historical_matches.map_name, 'Mapa no disponible') AS map_name,
|
||||||
|
COUNT(*) AS matches_count
|
||||||
|
FROM historical_matches
|
||||||
|
INNER JOIN historical_servers
|
||||||
|
ON historical_servers.id = historical_matches.historical_server_id
|
||||||
|
{where_clause}
|
||||||
|
GROUP BY historical_servers.slug, COALESCE(historical_matches.map_pretty_name, historical_matches.map_name, 'Mapa no disponible')
|
||||||
|
ORDER BY historical_servers.slug ASC, matches_count DESC, map_name ASC
|
||||||
|
""",
|
||||||
|
params,
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
top_maps_by_server: dict[str, list[dict[str, object]]] = {}
|
||||||
|
for row in map_rows:
|
||||||
|
server_key = str(row["server_slug"])
|
||||||
|
top_maps_by_server.setdefault(server_key, [])
|
||||||
|
if len(top_maps_by_server[server_key]) >= 3:
|
||||||
|
continue
|
||||||
|
top_maps_by_server[server_key].append(
|
||||||
|
{
|
||||||
|
"map_name": row["map_name"],
|
||||||
|
"matches_count": int(row["matches_count"] or 0),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
items: list[dict[str, object]] = []
|
||||||
|
for row in summary_rows:
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"server": {
|
||||||
|
"slug": row["server_slug"],
|
||||||
|
"name": row["server_name"],
|
||||||
|
},
|
||||||
|
"matches_count": int(row["matches_count"] or 0),
|
||||||
|
"unique_players": int(row["unique_players"] or 0),
|
||||||
|
"total_kills": int(row["total_kills"] or 0),
|
||||||
|
"map_count": int(row["map_count"] or 0),
|
||||||
|
"top_maps": top_maps_by_server.get(str(row["server_slug"]), []),
|
||||||
|
"time_range": {
|
||||||
|
"start": row["first_match_at"],
|
||||||
|
"end": row["last_match_at"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def get_historical_player_profile(
|
||||||
|
player_id: str,
|
||||||
|
*,
|
||||||
|
db_path: Path | None = None,
|
||||||
|
) -> dict[str, object] | None:
|
||||||
|
"""Return aggregate historical metrics for one player identity."""
|
||||||
|
resolved_player_id = player_id.strip()
|
||||||
|
if not resolved_player_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
resolved_path = initialize_historical_storage(db_path=db_path)
|
||||||
|
with _connect(resolved_path) as connection:
|
||||||
|
player_row = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
historical_players.id,
|
||||||
|
historical_players.stable_player_key,
|
||||||
|
historical_players.display_name,
|
||||||
|
historical_players.steam_id,
|
||||||
|
historical_players.source_player_id,
|
||||||
|
COUNT(DISTINCT historical_matches.id) AS matches_count,
|
||||||
|
COALESCE(SUM(historical_player_match_stats.kills), 0) AS total_kills,
|
||||||
|
MIN(COALESCE(historical_matches.ended_at, historical_matches.started_at, historical_matches.created_at_source)) AS first_match_at,
|
||||||
|
MAX(COALESCE(historical_matches.ended_at, historical_matches.started_at, historical_matches.created_at_source)) AS last_match_at
|
||||||
|
FROM historical_players
|
||||||
|
LEFT JOIN historical_player_match_stats
|
||||||
|
ON historical_player_match_stats.historical_player_id = historical_players.id
|
||||||
|
LEFT JOIN historical_matches
|
||||||
|
ON historical_matches.id = historical_player_match_stats.historical_match_id
|
||||||
|
WHERE historical_players.stable_player_key = ?
|
||||||
|
OR historical_players.steam_id = ?
|
||||||
|
OR historical_players.source_player_id = ?
|
||||||
|
GROUP BY historical_players.id
|
||||||
|
ORDER BY historical_players.display_name ASC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(resolved_player_id, resolved_player_id, resolved_player_id),
|
||||||
|
).fetchone()
|
||||||
|
if player_row is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
server_rows = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
historical_servers.slug AS server_slug,
|
||||||
|
historical_servers.display_name AS server_name,
|
||||||
|
COUNT(DISTINCT historical_matches.id) AS matches_count,
|
||||||
|
COALESCE(SUM(historical_player_match_stats.kills), 0) AS total_kills,
|
||||||
|
MIN(COALESCE(historical_matches.ended_at, historical_matches.started_at, historical_matches.created_at_source)) AS first_match_at,
|
||||||
|
MAX(COALESCE(historical_matches.ended_at, historical_matches.started_at, historical_matches.created_at_source)) AS last_match_at
|
||||||
|
FROM historical_player_match_stats
|
||||||
|
INNER JOIN historical_matches
|
||||||
|
ON historical_matches.id = historical_player_match_stats.historical_match_id
|
||||||
|
INNER JOIN historical_servers
|
||||||
|
ON historical_servers.id = historical_matches.historical_server_id
|
||||||
|
WHERE historical_player_match_stats.historical_player_id = ?
|
||||||
|
GROUP BY historical_servers.id
|
||||||
|
ORDER BY total_kills DESC, historical_servers.server_number ASC, historical_servers.slug ASC
|
||||||
|
""",
|
||||||
|
(player_row["id"],),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"player": {
|
||||||
|
"stable_player_key": player_row["stable_player_key"],
|
||||||
|
"name": player_row["display_name"],
|
||||||
|
"steam_id": player_row["steam_id"],
|
||||||
|
"source_player_id": player_row["source_player_id"],
|
||||||
|
},
|
||||||
|
"matches_count": int(player_row["matches_count"] or 0),
|
||||||
|
"total_kills": int(player_row["total_kills"] or 0),
|
||||||
|
"time_range": {
|
||||||
|
"start": player_row["first_match_at"],
|
||||||
|
"end": player_row["last_match_at"],
|
||||||
|
},
|
||||||
|
"servers": [
|
||||||
|
{
|
||||||
|
"server": {
|
||||||
|
"slug": row["server_slug"],
|
||||||
|
"name": row["server_name"],
|
||||||
|
},
|
||||||
|
"matches_count": int(row["matches_count"] or 0),
|
||||||
|
"total_kills": int(row["total_kills"] or 0),
|
||||||
|
"time_range": {
|
||||||
|
"start": row["first_match_at"],
|
||||||
|
"end": row["last_match_at"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for row in server_rows
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def list_weekly_top_kills(
|
def list_weekly_top_kills(
|
||||||
@@ -578,6 +788,18 @@ def _connect(db_path: Path) -> sqlite3.Connection:
|
|||||||
return connection
|
return connection
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_match_winner(allied_score: object, axis_score: object) -> str | None:
|
||||||
|
allied = _coerce_int(allied_score)
|
||||||
|
axis = _coerce_int(axis_score)
|
||||||
|
if allied is None or axis is None:
|
||||||
|
return None
|
||||||
|
if allied > axis:
|
||||||
|
return "allies"
|
||||||
|
if axis > allied:
|
||||||
|
return "axis"
|
||||||
|
return "draw"
|
||||||
|
|
||||||
|
|
||||||
def _has_legacy_historical_schema(connection: sqlite3.Connection) -> bool:
|
def _has_legacy_historical_schema(connection: sqlite3.Connection) -> bool:
|
||||||
columns = {
|
columns = {
|
||||||
str(row["name"])
|
str(row["name"])
|
||||||
|
|||||||
@@ -6,7 +6,12 @@ from datetime import datetime, timezone
|
|||||||
|
|
||||||
from .collector import collect_server_snapshots
|
from .collector import collect_server_snapshots
|
||||||
from .config import get_refresh_interval_seconds
|
from .config import get_refresh_interval_seconds
|
||||||
from .historical_storage import list_weekly_top_kills
|
from .historical_storage import (
|
||||||
|
get_historical_player_profile,
|
||||||
|
list_historical_server_summaries,
|
||||||
|
list_recent_historical_matches,
|
||||||
|
list_weekly_top_kills,
|
||||||
|
)
|
||||||
from .normalizers import normalize_map_name
|
from .normalizers import normalize_map_name
|
||||||
from .server_targets import load_a2s_targets
|
from .server_targets import load_a2s_targets
|
||||||
from .storage import list_latest_snapshots, list_server_history, list_snapshot_history
|
from .storage import list_latest_snapshots, list_server_history, list_snapshot_history
|
||||||
@@ -205,6 +210,60 @@ def build_weekly_top_kills_payload(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_recent_historical_matches_payload(
|
||||||
|
*,
|
||||||
|
limit: int = 20,
|
||||||
|
server_slug: str | None = None,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
"""Return recent historical matches from persisted CRCON data."""
|
||||||
|
items = list_recent_historical_matches(limit=limit, server_slug=server_slug)
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"data": {
|
||||||
|
"title": "Partidas recientes por servidor",
|
||||||
|
"context": "historical-recent-matches",
|
||||||
|
"source": "historical-crcon-storage",
|
||||||
|
"limit": limit,
|
||||||
|
"server_slug": server_slug,
|
||||||
|
"items": items,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_historical_server_summary_payload(
|
||||||
|
*,
|
||||||
|
server_slug: str | None = None,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
"""Return aggregated historical metrics per server."""
|
||||||
|
items = list_historical_server_summaries(server_slug=server_slug)
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"data": {
|
||||||
|
"title": "Resumen historico por servidor",
|
||||||
|
"context": "historical-server-summary",
|
||||||
|
"source": "historical-crcon-storage",
|
||||||
|
"server_slug": server_slug,
|
||||||
|
"items": items,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_historical_player_profile_payload(player_id: str) -> dict[str, object]:
|
||||||
|
"""Return aggregate historical metrics for one player identity."""
|
||||||
|
profile = get_historical_player_profile(player_id)
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"data": {
|
||||||
|
"title": "Perfil historico de jugador",
|
||||||
|
"context": "historical-player-profile",
|
||||||
|
"source": "historical-crcon-storage",
|
||||||
|
"player_id": player_id,
|
||||||
|
"found": profile is not None,
|
||||||
|
"profile": profile,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _enrich_server_items(items: list[dict[str, object]]) -> list[dict[str, object]]:
|
def _enrich_server_items(items: list[dict[str, object]]) -> list[dict[str, object]]:
|
||||||
target_index = {
|
target_index = {
|
||||||
target.external_server_id: target
|
target.external_server_id: target
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ from .payloads import (
|
|||||||
build_discord_payload,
|
build_discord_payload,
|
||||||
build_error_payload,
|
build_error_payload,
|
||||||
build_health_payload,
|
build_health_payload,
|
||||||
|
build_historical_player_profile_payload,
|
||||||
|
build_historical_server_summary_payload,
|
||||||
|
build_recent_historical_matches_payload,
|
||||||
build_server_detail_history_payload,
|
build_server_detail_history_payload,
|
||||||
build_server_history_payload,
|
build_server_history_payload,
|
||||||
build_server_latest_payload,
|
build_server_latest_payload,
|
||||||
@@ -47,6 +50,26 @@ def resolve_get_payload(path: str) -> tuple[HTTPStatus | None, dict[str, object]
|
|||||||
server_id = parse_qs(parsed.query).get("server", [None])[0]
|
server_id = parse_qs(parsed.query).get("server", [None])[0]
|
||||||
return HTTPStatus.OK, build_weekly_top_kills_payload(limit=limit, server_id=server_id)
|
return HTTPStatus.OK, build_weekly_top_kills_payload(limit=limit, server_id=server_id)
|
||||||
|
|
||||||
|
if parsed.path == "/api/historical/recent-matches":
|
||||||
|
limit = _parse_limit(parsed.query)
|
||||||
|
if limit is None:
|
||||||
|
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid limit parameter")
|
||||||
|
server_slug = parse_qs(parsed.query).get("server", [None])[0]
|
||||||
|
return HTTPStatus.OK, build_recent_historical_matches_payload(
|
||||||
|
limit=limit,
|
||||||
|
server_slug=server_slug,
|
||||||
|
)
|
||||||
|
|
||||||
|
if parsed.path == "/api/historical/server-summary":
|
||||||
|
server_slug = parse_qs(parsed.query).get("server", [None])[0]
|
||||||
|
return HTTPStatus.OK, build_historical_server_summary_payload(server_slug=server_slug)
|
||||||
|
|
||||||
|
if parsed.path == "/api/historical/player-profile":
|
||||||
|
player_id = parse_qs(parsed.query).get("player", [None])[0]
|
||||||
|
if not player_id:
|
||||||
|
return HTTPStatus.BAD_REQUEST, build_error_payload("Player parameter is required")
|
||||||
|
return HTTPStatus.OK, build_historical_player_profile_payload(player_id)
|
||||||
|
|
||||||
builder = GET_ROUTES.get(parsed.path)
|
builder = GET_ROUTES.get(parsed.path)
|
||||||
if builder is None:
|
if builder is None:
|
||||||
if parsed.path.startswith("/api/servers/") and parsed.path.endswith("/history"):
|
if parsed.path.startswith("/api/servers/") and parsed.path.endswith("/history"):
|
||||||
|
|||||||
242
frontend/assets/css/historico.css
Normal file
242
frontend/assets/css/historico.css
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
.historical-shell {
|
||||||
|
padding-bottom: 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historical-hero {
|
||||||
|
min-height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historical-hero__content {
|
||||||
|
padding-top: 44px;
|
||||||
|
padding-bottom: 46px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historical-hero__topline {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historical-hero__copy {
|
||||||
|
display: grid;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historical-hero__title {
|
||||||
|
max-width: 12ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historical-hero__text {
|
||||||
|
max-width: 60ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historical-content {
|
||||||
|
gap: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historical-panel {
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.historical-panel__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historical-selector {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historical-selector__button {
|
||||||
|
min-height: 48px;
|
||||||
|
padding: 0 18px;
|
||||||
|
border: 1px solid rgba(183, 201, 125, 0.28);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(180deg, rgba(24, 30, 22, 0.94), rgba(11, 14, 10, 0.98));
|
||||||
|
color: var(--text-soft);
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
transform 160ms ease,
|
||||||
|
border-color 160ms ease,
|
||||||
|
background 160ms ease,
|
||||||
|
color 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historical-selector__button:hover,
|
||||||
|
.historical-selector__button:focus-visible,
|
||||||
|
.historical-selector__button.is-active {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
border-color: rgba(210, 182, 118, 0.5);
|
||||||
|
background: linear-gradient(180deg, rgba(183, 201, 125, 0.18), rgba(89, 101, 58, 0.24));
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.historical-summary-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historical-stat-card,
|
||||||
|
.historical-match-card {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 18px;
|
||||||
|
border: 1px solid rgba(159, 168, 141, 0.16);
|
||||||
|
border-radius: 18px;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(28, 34, 25, 0.94), rgba(12, 15, 11, 0.98));
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.historical-stat-card p,
|
||||||
|
.historical-stat-card strong,
|
||||||
|
.historical-match-card p,
|
||||||
|
.historical-match-card strong {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historical-stat-card p,
|
||||||
|
.historical-match-meta__label {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historical-stat-card strong {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historical-state {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border: 1px dashed rgba(159, 168, 141, 0.28);
|
||||||
|
border-radius: 14px;
|
||||||
|
color: var(--text-soft);
|
||||||
|
background: rgba(13, 17, 12, 0.52);
|
||||||
|
}
|
||||||
|
|
||||||
|
.historical-state.is-error {
|
||||||
|
border-style: solid;
|
||||||
|
border-color: rgba(210, 182, 118, 0.28);
|
||||||
|
color: var(--accent-warm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.historical-state[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historical-table-shell {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historical-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
min-width: 620px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historical-table th,
|
||||||
|
.historical-table td {
|
||||||
|
padding: 14px 12px;
|
||||||
|
border-bottom: 1px solid rgba(159, 168, 141, 0.12);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historical-table th {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historical-table td {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.historical-table tbody tr:last-child td {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historical-table__position {
|
||||||
|
color: var(--accent-warm);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historical-match-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historical-match-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historical-match-card__top {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historical-match-card__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.08rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historical-match-card__result {
|
||||||
|
padding: 0.45rem 0.75rem;
|
||||||
|
border: 1px solid rgba(183, 201, 125, 0.24);
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--accent-strong);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historical-match-meta {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historical-match-meta strong {
|
||||||
|
display: block;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.historical-hero__topline,
|
||||||
|
.historical-panel__header,
|
||||||
|
.historical-match-card__top {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historical-selector {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historical-selector__button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historical-table {
|
||||||
|
min-width: 540px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -332,6 +332,47 @@ h2 {
|
|||||||
box-shadow: 0 12px 30px rgba(110, 127, 72, 0.35);
|
box-shadow: 0 12px 30px rgba(110, 127, 72, 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.secondary-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 52px;
|
||||||
|
min-width: 220px;
|
||||||
|
padding: 0 24px;
|
||||||
|
border: 1px solid rgba(210, 182, 118, 0.32);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(180deg, rgba(28, 34, 25, 0.9), rgba(12, 15, 11, 0.96));
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
transition:
|
||||||
|
transform 160ms ease,
|
||||||
|
border-color 160ms ease,
|
||||||
|
background 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-button:hover,
|
||||||
|
.secondary-button:focus-visible {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
border-color: rgba(210, 182, 118, 0.5);
|
||||||
|
background: linear-gradient(180deg, rgba(43, 50, 36, 0.94), rgba(16, 20, 14, 0.98));
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-button--ghost {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 0 18px;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-button--compact {
|
||||||
|
min-height: 44px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0 18px;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
}
|
||||||
|
|
||||||
.discord-button:hover,
|
.discord-button:hover,
|
||||||
.discord-button:focus-visible {
|
.discord-button:focus-visible {
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
@@ -410,6 +451,17 @@ h2 {
|
|||||||
max-width: 58ch;
|
max-width: 58ch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.panel__actions {
|
||||||
|
margin-top: 18px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel__actions--compact {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.panel--video {
|
.panel--video {
|
||||||
position: relative;
|
position: relative;
|
||||||
backdrop-filter: blur(4px);
|
backdrop-filter: blur(4px);
|
||||||
@@ -764,6 +816,10 @@ h2 {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.secondary-button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
margin-top: 18px;
|
margin-top: 18px;
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
|
|||||||
329
frontend/assets/js/historico.js
Normal file
329
frontend/assets/js/historico.js
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
const HISTORICAL_SERVER_SLUGS = Object.freeze([
|
||||||
|
"comunidad-hispana-01",
|
||||||
|
"comunidad-hispana-02",
|
||||||
|
]);
|
||||||
|
const DEFAULT_HISTORICAL_SERVER = HISTORICAL_SERVER_SLUGS[0];
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
const backendBaseUrl =
|
||||||
|
document.body.dataset.backendBaseUrl || "http://127.0.0.1:8000";
|
||||||
|
const selectorButtons = Array.from(
|
||||||
|
document.querySelectorAll("[data-server-slug]"),
|
||||||
|
);
|
||||||
|
const summaryNode = document.getElementById("historical-summary");
|
||||||
|
const rangeNode = document.getElementById("historical-range");
|
||||||
|
const weeklyStateNode = document.getElementById("weekly-top-kills-state");
|
||||||
|
const weeklyTableNode = document.getElementById("weekly-top-kills-table");
|
||||||
|
const weeklyBodyNode = document.getElementById("weekly-top-kills-body");
|
||||||
|
const recentStateNode = document.getElementById("recent-matches-state");
|
||||||
|
const recentListNode = document.getElementById("recent-matches-list");
|
||||||
|
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
let activeServerSlug = normalizeServerSlug(params.get("server"));
|
||||||
|
|
||||||
|
const refreshHistoricalView = async () => {
|
||||||
|
syncActiveButtons(selectorButtons, activeServerSlug);
|
||||||
|
setRangeBadge(rangeNode, "Cargando rango temporal", false);
|
||||||
|
renderSummaryLoading(summaryNode);
|
||||||
|
setState(weeklyStateNode, "Cargando ranking semanal...");
|
||||||
|
setState(recentStateNode, "Cargando partidas recientes...");
|
||||||
|
weeklyTableNode.hidden = true;
|
||||||
|
recentListNode.innerHTML = "";
|
||||||
|
|
||||||
|
const [summaryResult, topKillsResult, recentMatchesResult] =
|
||||||
|
await Promise.allSettled([
|
||||||
|
fetchJson(
|
||||||
|
`${backendBaseUrl}/api/historical/server-summary?server=${encodeURIComponent(activeServerSlug)}`,
|
||||||
|
),
|
||||||
|
fetchJson(
|
||||||
|
`${backendBaseUrl}/api/historical/weekly-top-kills?server=${encodeURIComponent(activeServerSlug)}&limit=10`,
|
||||||
|
),
|
||||||
|
fetchJson(
|
||||||
|
`${backendBaseUrl}/api/historical/recent-matches?server=${encodeURIComponent(activeServerSlug)}&limit=6`,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
hydrateSummary(summaryResult, summaryNode, rangeNode);
|
||||||
|
hydrateWeeklyTopKills(
|
||||||
|
topKillsResult,
|
||||||
|
weeklyStateNode,
|
||||||
|
weeklyTableNode,
|
||||||
|
weeklyBodyNode,
|
||||||
|
);
|
||||||
|
hydrateRecentMatches(recentMatchesResult, recentStateNode, recentListNode);
|
||||||
|
};
|
||||||
|
|
||||||
|
selectorButtons.forEach((button) => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
const nextServerSlug = normalizeServerSlug(button.dataset.serverSlug);
|
||||||
|
if (nextServerSlug === activeServerSlug) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
activeServerSlug = nextServerSlug;
|
||||||
|
params.set("server", activeServerSlug);
|
||||||
|
window.history.replaceState({}, "", `?${params.toString()}`);
|
||||||
|
void refreshHistoricalView();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
void refreshHistoricalView();
|
||||||
|
});
|
||||||
|
|
||||||
|
function hydrateSummary(result, summaryNode, rangeNode) {
|
||||||
|
if (result.status !== "fulfilled") {
|
||||||
|
renderSummaryError(summaryNode);
|
||||||
|
setRangeBadge(rangeNode, "Resumen historico no disponible", false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = result.value?.data?.items;
|
||||||
|
if (!Array.isArray(items) || items.length === 0) {
|
||||||
|
renderSummaryEmpty(summaryNode);
|
||||||
|
setRangeBadge(rangeNode, "Sin cobertura historica", false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = items[0];
|
||||||
|
const timeRange = summary.time_range || {};
|
||||||
|
const rangeLabel = buildRangeLabel(timeRange.start, timeRange.end);
|
||||||
|
setRangeBadge(
|
||||||
|
rangeNode,
|
||||||
|
rangeLabel || "Cobertura parcial disponible",
|
||||||
|
Boolean(rangeLabel),
|
||||||
|
);
|
||||||
|
summaryNode.innerHTML = [
|
||||||
|
renderSummaryCard("Servidor", summary.server?.name || "Servidor no disponible"),
|
||||||
|
renderSummaryCard("Partidas", formatNumber(summary.matches_count)),
|
||||||
|
renderSummaryCard("Jugadores unicos", formatNumber(summary.unique_players)),
|
||||||
|
renderSummaryCard("Kills agregadas", formatNumber(summary.total_kills)),
|
||||||
|
renderSummaryCard(
|
||||||
|
"Mapas dominantes",
|
||||||
|
formatTopMaps(summary.top_maps),
|
||||||
|
),
|
||||||
|
].join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function hydrateWeeklyTopKills(
|
||||||
|
result,
|
||||||
|
stateNode,
|
||||||
|
tableNode,
|
||||||
|
bodyNode,
|
||||||
|
) {
|
||||||
|
if (result.status !== "fulfilled") {
|
||||||
|
setState(stateNode, "No se pudo cargar el ranking semanal.", true);
|
||||||
|
tableNode.hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = result.value?.data?.items;
|
||||||
|
if (!Array.isArray(items) || items.length === 0) {
|
||||||
|
setState(
|
||||||
|
stateNode,
|
||||||
|
"Sin datos historicos suficientes para construir el ranking semanal.",
|
||||||
|
);
|
||||||
|
tableNode.hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyNode.innerHTML = items
|
||||||
|
.map(
|
||||||
|
(item) => `
|
||||||
|
<tr>
|
||||||
|
<td class="historical-table__position">#${escapeHtml(item.ranking_position)}</td>
|
||||||
|
<td>${escapeHtml(item.player?.name || "Jugador no identificado")}</td>
|
||||||
|
<td>${escapeHtml(formatNumber(item.weekly_kills))}</td>
|
||||||
|
<td>${escapeHtml(formatNumber(item.matches_considered))}</td>
|
||||||
|
<td>${escapeHtml(item.server?.name || "Servidor no disponible")}</td>
|
||||||
|
</tr>
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
stateNode.hidden = true;
|
||||||
|
tableNode.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hydrateRecentMatches(result, stateNode, listNode) {
|
||||||
|
if (result.status !== "fulfilled") {
|
||||||
|
setState(stateNode, "No se pudieron cargar las partidas recientes.", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = result.value?.data?.items;
|
||||||
|
if (!Array.isArray(items) || items.length === 0) {
|
||||||
|
setState(stateNode, "Todavia no hay partidas recientes disponibles.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
listNode.innerHTML = items.map((item) => renderRecentMatchCard(item)).join("");
|
||||||
|
stateNode.hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRecentMatchCard(item) {
|
||||||
|
const mapName = item.map?.pretty_name || item.map?.name || "Mapa no disponible";
|
||||||
|
return `
|
||||||
|
<article class="historical-match-card">
|
||||||
|
<div class="historical-match-card__top">
|
||||||
|
<div>
|
||||||
|
<p class="historical-match-meta__label">Partida ${escapeHtml(item.match_id || "sin id")}</p>
|
||||||
|
<h3 class="historical-match-card__title">${escapeHtml(mapName)}</h3>
|
||||||
|
</div>
|
||||||
|
<span class="historical-match-card__result">${escapeHtml(formatMatchResult(item.result))}</span>
|
||||||
|
</div>
|
||||||
|
<div class="historical-match-meta">
|
||||||
|
<article>
|
||||||
|
<p class="historical-match-meta__label">Servidor</p>
|
||||||
|
<strong>${escapeHtml(item.server?.name || "Servidor no disponible")}</strong>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<p class="historical-match-meta__label">Cierre</p>
|
||||||
|
<strong>${escapeHtml(formatTimestamp(item.closed_at))}</strong>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<p class="historical-match-meta__label">Jugadores</p>
|
||||||
|
<strong>${escapeHtml(formatNumber(item.player_count))}</strong>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<p class="historical-match-meta__label">Marcador</p>
|
||||||
|
<strong>${escapeHtml(formatScore(item.result))}</strong>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSummaryLoading(summaryNode) {
|
||||||
|
summaryNode.innerHTML = renderSummaryCard("Estado", "Cargando datos historicos");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSummaryError(summaryNode) {
|
||||||
|
summaryNode.innerHTML = renderSummaryCard("Estado", "Error al cargar el resumen");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSummaryEmpty(summaryNode) {
|
||||||
|
summaryNode.innerHTML = renderSummaryCard("Estado", "Sin datos historicos suficientes");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSummaryCard(label, value) {
|
||||||
|
return `
|
||||||
|
<article class="historical-stat-card">
|
||||||
|
<p>${escapeHtml(label)}</p>
|
||||||
|
<strong>${escapeHtml(value)}</strong>
|
||||||
|
</article>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setState(node, message, isError = false) {
|
||||||
|
node.textContent = message;
|
||||||
|
node.hidden = false;
|
||||||
|
node.classList.toggle("is-error", isError);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRangeBadge(node, label, isFresh) {
|
||||||
|
node.textContent = label;
|
||||||
|
node.classList.toggle("status-chip--ok", isFresh);
|
||||||
|
node.classList.toggle("status-chip--fallback", !isFresh);
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncActiveButtons(buttons, activeServerSlug) {
|
||||||
|
buttons.forEach((button) => {
|
||||||
|
button.classList.toggle(
|
||||||
|
"is-active",
|
||||||
|
button.dataset.serverSlug === activeServerSlug,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeServerSlug(rawValue) {
|
||||||
|
const normalized = typeof rawValue === "string" ? rawValue.trim() : "";
|
||||||
|
if (HISTORICAL_SERVER_SLUGS.includes(normalized)) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
return DEFAULT_HISTORICAL_SERVER;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRangeLabel(start, end) {
|
||||||
|
if (!start && !end) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${formatTimestamp(start)} a ${formatTimestamp(end)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTopMaps(topMaps) {
|
||||||
|
if (!Array.isArray(topMaps) || topMaps.length === 0) {
|
||||||
|
return "Sin mapas dominantes";
|
||||||
|
}
|
||||||
|
|
||||||
|
return topMaps
|
||||||
|
.map((item) => `${item.map_name} (${formatNumber(item.matches_count)})`)
|
||||||
|
.join(" / ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMatchResult(result) {
|
||||||
|
const winner = result?.winner;
|
||||||
|
if (winner === "allies") {
|
||||||
|
return "Victoria Aliada";
|
||||||
|
}
|
||||||
|
if (winner === "axis") {
|
||||||
|
return "Victoria Axis";
|
||||||
|
}
|
||||||
|
if (winner === "draw") {
|
||||||
|
return "Empate";
|
||||||
|
}
|
||||||
|
return "Resultado parcial";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatScore(result) {
|
||||||
|
const alliedScore = Number.isFinite(result?.allied_score)
|
||||||
|
? result.allied_score
|
||||||
|
: "-";
|
||||||
|
const axisScore = Number.isFinite(result?.axis_score)
|
||||||
|
? result.axis_score
|
||||||
|
: "-";
|
||||||
|
return `${alliedScore} - ${axisScore}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(value) {
|
||||||
|
const parsedValue = Number(value);
|
||||||
|
if (!Number.isFinite(parsedValue)) {
|
||||||
|
return "0";
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Intl.NumberFormat("es-ES").format(parsedValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(timestamp) {
|
||||||
|
if (!timestamp) {
|
||||||
|
return "Fecha no disponible";
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = new Date(timestamp);
|
||||||
|
if (Number.isNaN(value.getTime())) {
|
||||||
|
return "Fecha no disponible";
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat("es-ES", {
|
||||||
|
dateStyle: "short",
|
||||||
|
timeStyle: "short",
|
||||||
|
}).format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJson(url) {
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Request failed with ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value)
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("'", "'");
|
||||||
|
}
|
||||||
124
frontend/historico.html
Normal file
124
frontend/historico.html
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Historico propio de HLL Vietnam con ranking semanal y partidas recientes por servidor."
|
||||||
|
/>
|
||||||
|
<title>Historico - HLL Vietnam</title>
|
||||||
|
<link rel="stylesheet" href="./assets/css/styles.css" />
|
||||||
|
<link rel="stylesheet" href="./assets/css/historico.css" />
|
||||||
|
</head>
|
||||||
|
<body data-backend-base-url="http://127.0.0.1:8000">
|
||||||
|
<div class="page-shell historical-shell">
|
||||||
|
<header class="hero historical-hero">
|
||||||
|
<div class="hero__overlay"></div>
|
||||||
|
<div class="hero__content historical-hero__content">
|
||||||
|
<div class="historical-hero__topline">
|
||||||
|
<a class="secondary-button secondary-button--ghost" href="./index.html">
|
||||||
|
Volver a la landing
|
||||||
|
</a>
|
||||||
|
<p class="eyebrow">Historico propio</p>
|
||||||
|
</div>
|
||||||
|
<div class="historical-hero__copy">
|
||||||
|
<div>
|
||||||
|
<h1 class="historical-hero__title">
|
||||||
|
Registro tactico
|
||||||
|
<span class="hero__title-accent">Comunidad Hispana</span>
|
||||||
|
</h1>
|
||||||
|
<p class="hero__text historical-hero__text">
|
||||||
|
Consulta el ranking semanal y las partidas recientes usando solo
|
||||||
|
la capa historica interna del proyecto.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="historical-selector" aria-label="Seleccion de servidor">
|
||||||
|
<button
|
||||||
|
class="historical-selector__button is-active"
|
||||||
|
type="button"
|
||||||
|
data-server-slug="comunidad-hispana-01"
|
||||||
|
>
|
||||||
|
Comunidad Hispana #01
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="historical-selector__button"
|
||||||
|
type="button"
|
||||||
|
data-server-slug="comunidad-hispana-02"
|
||||||
|
>
|
||||||
|
Comunidad Hispana #02
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="content historical-content">
|
||||||
|
<section class="panel historical-panel" aria-labelledby="historical-summary-title">
|
||||||
|
<div class="panel__shell">
|
||||||
|
<div class="panel__header historical-panel__header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow eyebrow--section">Cobertura</p>
|
||||||
|
<h2 id="historical-summary-title">Resumen del servidor</h2>
|
||||||
|
</div>
|
||||||
|
<p class="status-chip status-chip--fallback" id="historical-range">
|
||||||
|
Cargando rango temporal
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="historical-summary-grid" id="historical-summary">
|
||||||
|
<article class="historical-stat-card">
|
||||||
|
<p>Estado</p>
|
||||||
|
<strong>Cargando datos historicos</strong>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel historical-panel" aria-labelledby="weekly-ranking-title">
|
||||||
|
<div class="panel__shell">
|
||||||
|
<div class="panel__header historical-panel__header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow eyebrow--section">Ranking semanal</p>
|
||||||
|
<h2 id="weekly-ranking-title">Top kills de los ultimos 7 dias</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="historical-state" id="weekly-top-kills-state" aria-live="polite">
|
||||||
|
Cargando ranking semanal...
|
||||||
|
</p>
|
||||||
|
<div class="historical-table-shell">
|
||||||
|
<table class="historical-table" id="weekly-top-kills-table" hidden>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Pos.</th>
|
||||||
|
<th>Jugador</th>
|
||||||
|
<th>Kills</th>
|
||||||
|
<th>Partidas</th>
|
||||||
|
<th>Servidor</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="weekly-top-kills-body"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel historical-panel" aria-labelledby="recent-matches-title">
|
||||||
|
<div class="panel__shell">
|
||||||
|
<div class="panel__header historical-panel__header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow eyebrow--section">Partidas recientes</p>
|
||||||
|
<h2 id="recent-matches-title">Ultimos cierres registrados</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="historical-state" id="recent-matches-state" aria-live="polite">
|
||||||
|
Cargando partidas recientes...
|
||||||
|
</p>
|
||||||
|
<div class="historical-match-list" id="recent-matches-list"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="./assets/js/historico.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -47,6 +47,9 @@
|
|||||||
>
|
>
|
||||||
Unirse al Discord
|
Unirse al Discord
|
||||||
</a>
|
</a>
|
||||||
|
<a class="secondary-button" href="./historico.html">
|
||||||
|
Ver historico propio
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -88,6 +91,11 @@
|
|||||||
<p class="panel__intro panel__intro--tight">
|
<p class="panel__intro panel__intro--tight">
|
||||||
El panel muestra el ultimo snapshot disponible consultado desde backend.
|
El panel muestra el ultimo snapshot disponible consultado desde backend.
|
||||||
</p>
|
</p>
|
||||||
|
<div class="panel__actions panel__actions--compact">
|
||||||
|
<a class="secondary-button secondary-button--compact" href="./historico.html">
|
||||||
|
Abrir zona historica del proyecto
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
<div class="servers-grid" id="servers-list">
|
<div class="servers-grid" id="servers-list">
|
||||||
<article class="server-card server-card--stats server-card--real">
|
<article class="server-card server-card--stats server-card--real">
|
||||||
<div class="server-card__top server-card__top--stats">
|
<div class="server-card__top server-card__top--stats">
|
||||||
|
|||||||
Reference in New Issue
Block a user