diff --git a/ai/tasks/done/TASK-057-server-03-historical-bootstrap-and-snapshots.md b/ai/tasks/done/TASK-057-server-03-historical-bootstrap-and-snapshots.md new file mode 100644 index 0000000..0263f04 --- /dev/null +++ b/ai/tasks/done/TASK-057-server-03-historical-bootstrap-and-snapshots.md @@ -0,0 +1,70 @@ +# TASK-057-server-03-historical-bootstrap-and-snapshots + +## Goal +Cargar histórico real para `comunidad-hispana-03` desde su fuente CRCON configurada, persistirlo en la base histórica del proyecto y generar los snapshots necesarios para que la UI deje de mostrarlo como pendiente de bootstrap. + +## Context +El servidor `comunidad-hispana-03` ya existe en la configuración del proyecto, pero actualmente la UI lo muestra con un estado honesto de “pendiente de histórico” porque todavía no se ha ejecutado bootstrap/backfill real para ese servidor. El objetivo de esta task es convertir ese servidor en un origen histórico real dentro del producto, dejándolo operativo igual que `#01` y `#02`. + +## Steps +1. Revisar la configuración actual del servidor `comunidad-hispana-03`. +2. Confirmar que la fuente histórica configurada sigue siendo válida y accesible. +3. Ejecutar bootstrap/backfill real para `comunidad-hispana-03` con un enfoque operativo seguro y reanudable. +4. Persistir en SQLite: + - matches + - players + - stats por match + - progreso/backfill +5. Generar snapshots JSON para `comunidad-hispana-03`, al menos de: + - server-summary + - weekly-kills + - weekly-deaths + - weekly-matches-over-100-kills + - weekly-support + - recent-matches +6. Verificar que la UI deja de mostrar el estado de “pendiente de bootstrap” si ya existe histórico real. +7. Documentar el resultado del bootstrap de `#03`, incluyendo cobertura alcanzada si es posible. +8. No tocar la semántica de #01, #02 ni Totales salvo lo estrictamente necesario. +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_snapshots.py +- backend/app/historical_snapshot_storage.py +- backend/app/historical_runner.py +- backend/app/payloads.py +- frontend/assets/js/historico.js +- docs/historical-coverage-report.md + +## Expected Files to Modify +- backend/README.md +- backend/app/historical_ingestion.py +- backend/app/historical_snapshots.py +- backend/app/historical_snapshot_storage.py +- opcionalmente docs/historical-coverage-report.md +- y los snapshots generados bajo: + - backend/data/snapshots/comunidad-hispana-03/ + +## Constraints +- No usar A2S para esta carga histórica. +- No crear páginas nuevas. +- No romper #01, #02 ni Totales. +- No hacer cambios destructivos. +- Mantener el trabajo centrado en dejar #03 con histórico real y snapshots útiles. + +## Validation +- `comunidad-hispana-03` deja de aparecer como pendiente de bootstrap si hay histórico suficiente. +- Existen snapshots JSON no vacíos para `#03` cuando la fuente devuelve datos. +- El histórico bruto de `#03` queda persistido en SQLite. +- Los cambios quedan committeados y se hace push si el entorno lo permite. + +## Change Budget +- Preferir menos de 6 archivos modificados o creados, sin contar los snapshots generados. +- Preferir menos de 240 líneas cambiadas. diff --git a/ai/tasks/done/TASK-058-monthly-historical-leaderboards-api.md b/ai/tasks/done/TASK-058-monthly-historical-leaderboards-api.md new file mode 100644 index 0000000..3e19be6 --- /dev/null +++ b/ai/tasks/done/TASK-058-monthly-historical-leaderboards-api.md @@ -0,0 +1,67 @@ +# TASK-058-monthly-historical-leaderboards-api + +## Goal +Ampliar la API histórica de leaderboards para soportar una dimensión mensual además de la semanal, reutilizando las mismas métricas actuales del producto. + +## Context +La página histórica ya muestra tops semanales por métrica: +- kills +- muertes +- partidas con más de 100 kills +- soporte + +Ahora se quiere añadir una segunda dimensión temporal: mensual. La API debe poder servir snapshots o payloads equivalentes para el periodo mensual con la misma claridad que ya existe en semanal. + +## Steps +1. Revisar la implementación actual de tops semanales y su semántica temporal. +2. Diseñar una dimensión mensual coherente para leaderboards históricos. +3. Definir la política temporal mensual, idealmente basada en mes natural cerrado o en el mes actual si la política del proyecto lo requiere, dejándolo claro. +4. Implementar soporte backend para leaderboards mensuales con estas métricas: + - kills + - muertes + - partidas con más de 100 kills + - soporte +5. Asegurar que la API exponga metadatos claros del rango temporal real usado. +6. Integrar el soporte mensual en la capa de snapshots JSON en disco. +7. Mantener compatibilidad con la capa semanal existente. +8. Documentar la nueva capacidad en backend. +9. No crear todavía la pestaña visual en esta task si no es imprescindible. +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 +- backend/README.md +- backend/app/payloads.py +- backend/app/routes.py +- backend/app/historical_storage.py +- backend/app/historical_snapshots.py +- backend/app/historical_snapshot_storage.py +- frontend/assets/js/historico.js + +## Expected Files to Modify +- backend/app/payloads.py +- backend/app/routes.py +- backend/app/historical_storage.py +- backend/app/historical_snapshots.py +- backend/app/historical_snapshot_storage.py +- backend/README.md +- opcionalmente documentación técnica adicional si hace falta aclarar la política mensual + +## Constraints +- No romper tops semanales existentes. +- No usar A2S para estos tops históricos. +- No hacer cambios destructivos. +- Mantener el trabajo centrado en leaderboards mensuales y metadatos temporales claros. + +## Validation +- Existen leaderboards mensuales para las métricas soportadas. +- La API distingue correctamente entre semanal y mensual. +- Los snapshots mensuales se generan y persisten correctamente. +- 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. diff --git a/ai/tasks/done/TASK-059-historical-leaderboards-timeframe-tabs-ui.md b/ai/tasks/done/TASK-059-historical-leaderboards-timeframe-tabs-ui.md new file mode 100644 index 0000000..fce3dfc --- /dev/null +++ b/ai/tasks/done/TASK-059-historical-leaderboards-timeframe-tabs-ui.md @@ -0,0 +1,75 @@ +# TASK-059-historical-leaderboards-timeframe-tabs-ui + +## Goal +Ampliar la sección de tops de la página histórica para permitir alternar entre rankings semanales y mensuales dentro de la misma zona visual, manteniendo también las pestañas por métrica. + +## Context +La sección de tops ya tiene pestañas por métrica: +- Top kills +- Top muertes +- Partidas 100+ kills +- Soporte + +Ahora se quiere añadir una segunda capa de navegación temporal dentro de esa misma sección para consultar: +- semanal +- mensual + +La experiencia debe seguir siendo clara, rápida y coherente con el estilo actual de la página. + +## Steps +1. Revisar la UI actual de la sección de leaderboards. +2. Revisar la nueva API mensual y cómo convive con la semanal. +3. Diseñar una navegación clara para el marco temporal, por ejemplo: + - Semanal + - Mensual +4. Mantener las pestañas de métricas ya existentes dentro de ese marco temporal. +5. Hacer que la UI pueda alternar entre: + - semanal + kills + - semanal + muertes + - semanal + 100+ kills + - semanal + soporte + - mensual + kills + - mensual + muertes + - mensual + 100+ kills + - mensual + soporte +6. Mostrar el rango temporal real usado de forma clara y natural. +7. Mantener estados de loading, empty y error. +8. Mantener el rendimiento percibido razonable y compatible con la estrategia actual de snapshots. +9. No crear una nueva página; la ampliación debe quedar dentro de la sección actual de tops. +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 +- 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 uso + +## Constraints +- No romper el flujo actual semanal. +- No introducir frameworks nuevos. +- No crear páginas nuevas. +- No hacer cambios destructivos. +- Mantener el trabajo centrado en la navegación temporal dentro de los tops. + +## Validation +- La sección de tops permite alternar entre semanal y mensual. +- Siguen funcionando las métricas actuales. +- El rango temporal mostrado es claro. +- La UI sigue siendo coherente con el resto de la página histórica. +- Los cambios quedan committeados y se hace push si el entorno lo permite. + +## Change Budget +- Preferir menos de 5 archivos modificados. +- Preferir menos de 220 líneas cambiadas. diff --git a/backend/README.md b/backend/README.md index d4ee275..0da6411 100644 --- a/backend/README.md +++ b/backend/README.md @@ -127,10 +127,12 @@ normaliza espacios y barras finales para mantener la comparacion con el header - `GET /api/servers/{id}/history?limit=20` - `GET /api/historical/weekly-top-kills?limit=10&server=comunidad-hispana-01` - `GET /api/historical/weekly-leaderboard?metric=kills&limit=10&server=comunidad-hispana-01` +- `GET /api/historical/leaderboard?timeframe=monthly&metric=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/snapshots/server-summary?server=comunidad-hispana-01` - `GET /api/historical/snapshots/weekly-leaderboard?metric=kills&limit=10&server=comunidad-hispana-01` +- `GET /api/historical/snapshots/leaderboard?timeframe=monthly&metric=kills&limit=10&server=comunidad-hispana-01` - `GET /api/historical/snapshots/recent-matches?limit=6&server=comunidad-hispana-01` - `GET /api/historical/player-profile?player=steam%3A76561198000000000` @@ -226,6 +228,7 @@ bruto. Esta capa esta preparada para guardar: - `server-summary` - `weekly-leaderboard` con metricas `kills`, `deaths`, `support` y `matches_over_100_kills` +- `monthly-leaderboard` con las mismas metricas semanticas - `recent-matches` Por defecto se escriben bajo: @@ -477,10 +480,12 @@ La capa historica propia expone: - `/api/historical/weekly-top-kills` - `/api/historical/weekly-leaderboard` +- `/api/historical/leaderboard` - `/api/historical/recent-matches` - `/api/historical/server-summary` - `/api/historical/snapshots/server-summary` - `/api/historical/snapshots/weekly-leaderboard` +- `/api/historical/snapshots/leaderboard` - `/api/historical/snapshots/recent-matches` - `/api/historical/player-profile` @@ -514,6 +519,13 @@ Metricas soportadas: El endpoint legacy `/api/historical/weekly-top-kills` se conserva como alias compatible para la metrica `kills`. +`/api/historical/leaderboard` y `/api/historical/snapshots/leaderboard` +generalizan ese mismo contrato con `timeframe=weekly|monthly`. Para `monthly`, +la politica temporal usa el mes natural UTC en curso y hace fallback al mes +cerrado anterior solo cuando el mes actual todavia no tiene ningun cierre. +Ambas variantes exponen el rango real usado mediante `window_start`, +`window_end`, `window_kind`, `window_label` y `selection_reason`. + `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` @@ -558,6 +570,10 @@ payload ya persistido sin recalcularlo. `/api/historical/snapshots/recent-matche devuelve `items` de cierres recientes ya preparados y tambien acepta `limit` para servir solo una parte del snapshot persistido. +La misma capa de snapshots guarda tambien `monthly-leaderboard` por servidor y +por agregado `all-servers`, con archivos como `monthly-kills.json` y +`monthly-support.json`. + ## Ingesta historica CRCON La ingesta historica no usa A2S ni scraping del HTML de `/games`. Consume la @@ -639,6 +655,7 @@ correcto. Por defecto: - prewarmea en cada ciclo: - `server-summary` para `comunidad-hispana-01`, `comunidad-hispana-02`, `comunidad-hispana-03` y `all-servers` - `weekly-leaderboard` de la metrica por defecto `kills` para esos mismos alcances + - `monthly-leaderboard` de la metrica por defecto `kills` para esos mismos alcances - `recent-matches` para esos mismos alcances - recompone la matriz completa de snapshots cada `4` ciclos para mantener el resto de metricas al dia sin penalizar todos los refresh - reintenta hasta `2` veces tras un fallo @@ -648,6 +665,7 @@ correcto. Por defecto: - persiste por servidor: - `server-summary` - `weekly-leaderboard` para `kills`, `deaths`, `support` y `matches_over_100_kills` + - `monthly-leaderboard` para `kills`, `deaths`, `support` y `matches_over_100_kills` - `recent-matches` Flags utiles del runner: diff --git a/backend/app/historical_snapshot_storage.py b/backend/app/historical_snapshot_storage.py index a214dd0..d3c2b2d 100644 --- a/backend/app/historical_snapshot_storage.py +++ b/backend/app/historical_snapshot_storage.py @@ -3,7 +3,6 @@ from __future__ import annotations import json -import sqlite3 from datetime import datetime, timezone from pathlib import Path @@ -58,8 +57,6 @@ def persist_historical_snapshot( if _should_preserve_existing_snapshot( incoming_payload=payload, snapshot_type=snapshot_type, - server_key=normalized_server_key, - db_path=db_path, existing_document=existing_document, ): preserved_payload = existing_document.get("payload") if existing_document else payload @@ -222,8 +219,6 @@ def _should_preserve_existing_snapshot( *, incoming_payload: dict[str, object] | list[object], snapshot_type: str, - server_key: str, - db_path: Path | None, existing_document: dict[str, object] | None, ) -> bool: if not _is_effectively_empty_snapshot_payload(snapshot_type, incoming_payload): @@ -233,7 +228,7 @@ def _should_preserve_existing_snapshot( existing_document.get("payload"), ): return True - return _historical_data_exists(server_key=server_key, db_path=db_path) + return False def _is_effectively_empty_snapshot_payload( @@ -254,40 +249,11 @@ def _is_effectively_empty_snapshot_payload( items = payload.get("items") return not isinstance(items, list) or len(items) == 0 - if snapshot_type == "weekly-leaderboard": + if snapshot_type in {"weekly-leaderboard", "monthly-leaderboard"}: items = payload.get("items") return not isinstance(items, list) or len(items) == 0 return False - - -def _historical_data_exists(*, server_key: str, db_path: Path | None) -> bool: - resolved_db_path = initialize_historical_storage(db_path=db_path or get_storage_path()) - where_clause = "" - params: list[object] = [] - if server_key != "all-servers": - where_clause = "WHERE historical_servers.slug = ?" - params.append(server_key) - - connection = sqlite3.connect(resolved_db_path) - connection.row_factory = sqlite3.Row - try: - row = connection.execute( - f""" - SELECT COUNT(*) AS matches_count - FROM historical_matches - INNER JOIN historical_servers - ON historical_servers.id = historical_matches.historical_server_id - {where_clause} - """, - params, - ).fetchone() - finally: - connection.close() - - return bool(row and int(row["matches_count"] or 0) > 0) - - def _read_snapshot_document(snapshot_path: Path) -> dict[str, object] | None: if not snapshot_path.exists(): return None @@ -318,6 +284,9 @@ def _build_snapshot_filename(*, snapshot_type: str, metric: str | None) -> str: 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" + if snapshot_type == "monthly-leaderboard": + metric_suffix = "matches-over-100-kills" if metric == "matches_over_100_kills" else _slugify(metric or "unknown") + return f"monthly-{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" diff --git a/backend/app/historical_snapshots.py b/backend/app/historical_snapshots.py index ab5da74..8492e95 100644 --- a/backend/app/historical_snapshots.py +++ b/backend/app/historical_snapshots.py @@ -9,18 +9,21 @@ from .historical_storage import ( ALL_SERVERS_SLUG, list_historical_server_summaries, list_historical_servers, + list_monthly_leaderboard, list_recent_historical_matches, list_weekly_leaderboard, ) SNAPSHOT_TYPE_SERVER_SUMMARY = "server-summary" SNAPSHOT_TYPE_WEEKLY_LEADERBOARD = "weekly-leaderboard" +SNAPSHOT_TYPE_MONTHLY_LEADERBOARD = "monthly-leaderboard" SNAPSHOT_TYPE_RECENT_MATCHES = "recent-matches" SUPPORTED_SNAPSHOT_TYPES = frozenset( { SNAPSHOT_TYPE_SERVER_SUMMARY, SNAPSHOT_TYPE_WEEKLY_LEADERBOARD, + SNAPSHOT_TYPE_MONTHLY_LEADERBOARD, SNAPSHOT_TYPE_RECENT_MATCHES, } ) @@ -49,6 +52,7 @@ SNAPSHOT_LEADERBOARD_METRICS = ( DEFAULT_SNAPSHOT_WINDOW = "all-time" DEFAULT_WEEKLY_SNAPSHOT_WINDOW = "7d" +DEFAULT_MONTHLY_SNAPSHOT_WINDOW = "month" DEFAULT_WEEKLY_LEADERBOARD_LIMIT = 10 DEFAULT_RECENT_MATCHES_LIMIT = 20 @@ -62,13 +66,18 @@ def validate_snapshot_identity( if snapshot_type not in SUPPORTED_SNAPSHOT_TYPES: raise ValueError(f"Unsupported historical snapshot type: {snapshot_type}") - if snapshot_type == SNAPSHOT_TYPE_WEEKLY_LEADERBOARD: + if snapshot_type in { + SNAPSHOT_TYPE_WEEKLY_LEADERBOARD, + SNAPSHOT_TYPE_MONTHLY_LEADERBOARD, + }: if metric not in SUPPORTED_LEADERBOARD_METRICS: raise ValueError(f"Unsupported historical snapshot metric: {metric}") return if metric is not None: - raise ValueError(f"Metric is only supported for {SNAPSHOT_TYPE_WEEKLY_LEADERBOARD}.") + raise ValueError( + "Metric is only supported for weekly-leaderboard and monthly-leaderboard." + ) def list_snapshot_server_keys(*, db_path: Path | None = None) -> list[str]: @@ -104,6 +113,15 @@ def build_historical_server_snapshots( db_path=db_path, ) ) + snapshots.append( + _build_monthly_leaderboard_snapshot( + server_key, + metric, + generated_at_value, + limit=leaderboard_limit, + db_path=db_path, + ) + ) snapshots.append( _build_recent_matches_snapshot( @@ -141,6 +159,15 @@ def build_priority_historical_snapshots( db_path=db_path, ) ) + snapshots.append( + _build_monthly_leaderboard_snapshot( + server_key, + metric, + generated_at_value, + limit=leaderboard_limit, + db_path=db_path, + ) + ) snapshots.append( _build_recent_matches_snapshot( server_key, @@ -305,6 +332,39 @@ def _build_weekly_leaderboard_snapshot( } +def _build_monthly_leaderboard_snapshot( + server_key: str, + metric: str, + generated_at: datetime, + *, + limit: int, + db_path: Path | None = None, +) -> dict[str, object]: + leaderboard_result = list_monthly_leaderboard( + limit=limit, + server_id=server_key, + metric=metric, + db_path=db_path, + ) + return { + "server_key": server_key, + "snapshot_type": SNAPSHOT_TYPE_MONTHLY_LEADERBOARD, + "metric": metric, + "window": DEFAULT_MONTHLY_SNAPSHOT_WINDOW, + "generated_at": generated_at, + "source_range_start": _parse_optional_timestamp(leaderboard_result.get("window_start")), + "source_range_end": _parse_optional_timestamp(leaderboard_result.get("window_end")), + "is_stale": False, + "payload": { + "server_key": server_key, + "metric": metric, + "limit": limit, + "generated_at": _to_iso(generated_at), + **leaderboard_result, + }, + } + + def _build_recent_matches_snapshot( server_key: str, generated_at: datetime, diff --git a/backend/app/historical_storage.py b/backend/app/historical_storage.py index bd7d8ea..4107d3c 100644 --- a/backend/app/historical_storage.py +++ b/backend/app/historical_storage.py @@ -47,6 +47,7 @@ SUPPORTED_WEEKLY_LEADERBOARD_METRICS = frozenset( "matches_over_100_kills", } ) +SUPPORTED_MONTHLY_LEADERBOARD_METRICS = SUPPORTED_WEEKLY_LEADERBOARD_METRICS def initialize_historical_storage(*, db_path: Path | None = None) -> Path: @@ -1289,6 +1290,165 @@ def list_weekly_top_kills( } +def list_monthly_leaderboard( + *, + limit: int = 10, + server_id: str | None = None, + metric: str = "kills", + db_path: Path | None = None, +) -> dict[str, object]: + """Return ranked monthly 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_month_start = _start_of_month(current_time) + previous_month_start = _start_of_previous_month(current_month_start) + normalized_metric = metric.strip() if isinstance(metric, str) else "" + if normalized_metric not in SUPPORTED_MONTHLY_LEADERBOARD_METRICS: + raise ValueError(f"Unsupported monthly leaderboard metric: {metric}") + + monthly_window = _select_monthly_window( + server_id=server_id, + current_time=current_time, + current_month_start=current_month_start, + previous_month_start=previous_month_start, + db_path=resolved_path, + ) + window_start = monthly_window["window_start"] + window_end = monthly_window["window_end"] + where_clauses = [ + "historical_matches.ended_at IS NOT NULL", + "historical_matches.ended_at >= ?", + "historical_matches.ended_at < ?", + ] + params: list[object] = [ + window_start.isoformat().replace("+00:00", "Z"), + window_end.isoformat().replace("+00:00", "Z"), + ] + 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)", + "support": "COALESCE(SUM(historical_player_match_stats.support), 0)", + "matches_over_100_kills": ( + "COALESCE(SUM(CASE WHEN COALESCE(historical_player_match_stats.kills, 0) >= 100 " + "THEN 1 ELSE 0 END), 0)" + ), + }[normalized_metric] + + with _connect(resolved_path) as connection: + rows = connection.execute( + f""" + WITH ranked_players AS ( + SELECT + {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 {partition_expression} + ORDER BY + {metric_sum_expression} DESC, + COUNT(DISTINCT historical_matches.id) ASC, + historical_players.display_name ASC + ) AS ranking_position + 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 + INNER JOIN historical_players + ON historical_players.id = historical_player_match_stats.historical_player_id + WHERE {" AND ".join(where_clauses)} + GROUP BY {group_by_expression} + ) + SELECT * + FROM ranked_players + WHERE ranking_position <= ? + ORDER BY server_slug ASC, ranking_position ASC + """, + [*params, limit], + ).fetchall() + + window_days = _calculate_window_days(window_start=window_start, window_end=window_end) + items: list[dict[str, object]] = [] + for row in rows: + items.append( + { + "server": { + "slug": row["server_slug"], + "name": row["server_name"], + }, + "time_range": { + "start": window_start.isoformat().replace("+00:00", "Z"), + "end": window_end.isoformat().replace("+00:00", "Z"), + "window_days": window_days, + }, + "player": { + "stable_player_key": row["stable_player_key"], + "name": row["player_name"], + "steam_id": row["steam_id"], + }, + "metric": normalized_metric, + "ranking_position": int(row["ranking_position"]), + "metric_value": int(row["metric_value"] or 0), + "matches_considered": int(row["matches_count"] or 0), + } + ) + + return { + "timeframe": "monthly", + "metric": normalized_metric, + "window_start": window_start.isoformat().replace("+00:00", "Z"), + "window_end": window_end.isoformat().replace("+00:00", "Z"), + "window_days": window_days, + "window_kind": monthly_window["window_kind"], + "window_label": monthly_window["window_label"], + "uses_fallback": monthly_window["uses_fallback"], + "selection_reason": monthly_window["selection_reason"], + "current_month_start": current_month_start.isoformat().replace("+00:00", "Z"), + "current_month_closed_matches": monthly_window["current_month_closed_matches"], + "previous_month_closed_matches": monthly_window["previous_month_closed_matches"], + "sufficient_sample": { + "minimum_closed_matches": monthly_window["minimum_closed_matches"], + "current_month_closed_matches": monthly_window["current_month_closed_matches"], + "current_month_has_sufficient_sample": monthly_window["current_month_has_sufficient_sample"], + "is_early_month": monthly_window["is_early_month"], + }, + "items": items, + } + + def _connect(db_path: Path) -> sqlite3.Connection: connection = sqlite3.connect(db_path) connection.row_factory = sqlite3.Row @@ -2236,6 +2396,59 @@ def _select_weekly_window( } +def _select_monthly_window( + *, + server_id: str | None, + current_time: datetime, + current_month_start: datetime, + previous_month_start: datetime, + db_path: Path, +) -> dict[str, object]: + current_month_closed_matches = _count_closed_matches_in_window( + server_id=server_id, + window_start=current_month_start, + window_end=current_time, + db_path=db_path, + ) + previous_month_closed_matches = _count_closed_matches_in_window( + server_id=server_id, + window_start=previous_month_start, + window_end=current_month_start, + db_path=db_path, + ) + is_early_month = current_time.day <= 3 + uses_fallback = current_month_closed_matches <= 0 and previous_month_closed_matches > 0 + + if uses_fallback: + return { + "window_start": previous_month_start, + "window_end": current_month_start, + "window_kind": "previous-closed-month-fallback", + "window_label": "Mes cerrado anterior", + "uses_fallback": True, + "selection_reason": "no-current-month-matches", + "minimum_closed_matches": 1, + "current_month_closed_matches": current_month_closed_matches, + "previous_month_closed_matches": previous_month_closed_matches, + "current_month_has_sufficient_sample": False, + "is_early_month": is_early_month, + } + + return { + "window_start": current_month_start, + "window_end": current_time, + "window_kind": "current-month", + "window_label": "Mes actual", + "uses_fallback": False, + "selection_reason": "current-month", + "minimum_closed_matches": 1, + "current_month_closed_matches": current_month_closed_matches, + "previous_month_closed_matches": previous_month_closed_matches, + "current_month_has_sufficient_sample": current_month_closed_matches > 0, + "is_early_month": is_early_month, + } + + def _count_closed_matches_in_window( *, server_id: str | None, @@ -2408,6 +2621,21 @@ def _start_of_week(value: datetime) -> datetime: return midnight - timedelta(days=midnight.weekday()) +def _start_of_month(value: datetime) -> datetime: + normalized = value.astimezone(timezone.utc) + return normalized.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + + +def _start_of_previous_month(value: datetime) -> datetime: + previous_day = value - timedelta(days=1) + return _start_of_month(previous_day) + + +def _calculate_window_days(*, window_start: datetime, window_end: datetime) -> int: + delta = window_end - window_start + return max(1, int((delta.total_seconds() + 86399) // 86400)) + + def _get_nested(payload: Mapping[str, object], *path: str) -> object: current: object = payload for key in path: diff --git a/backend/app/payloads.py b/backend/app/payloads.py index 7080112..1279829 100644 --- a/backend/app/payloads.py +++ b/backend/app/payloads.py @@ -8,8 +8,10 @@ from .collector import collect_server_snapshots from .config import get_refresh_interval_seconds from .historical_snapshot_storage import get_historical_snapshot from .historical_snapshots import ( + DEFAULT_MONTHLY_SNAPSHOT_WINDOW, DEFAULT_SNAPSHOT_WINDOW, DEFAULT_WEEKLY_SNAPSHOT_WINDOW, + SNAPSHOT_TYPE_MONTHLY_LEADERBOARD, SNAPSHOT_TYPE_RECENT_MATCHES, SNAPSHOT_TYPE_SERVER_SUMMARY, SNAPSHOT_TYPE_WEEKLY_LEADERBOARD, @@ -18,6 +20,7 @@ from .historical_storage import ( ALL_SERVERS_SLUG, get_historical_player_profile, list_historical_server_summaries, + list_monthly_leaderboard, list_recent_historical_matches, list_weekly_leaderboard, list_weekly_top_kills, @@ -221,36 +224,38 @@ def build_weekly_top_kills_payload( } -def build_weekly_leaderboard_payload( +def build_historical_leaderboard_payload( *, limit: int = 10, server_id: str | None = None, metric: str = "kills", + timeframe: str = "weekly", ) -> dict[str, object]: - """Return one weekly historical leaderboard for the requested metric.""" - result = list_weekly_leaderboard(limit=limit, server_id=server_id, metric=metric) + """Return one historical leaderboard for the requested timeframe and metric.""" + normalized_timeframe = timeframe.strip().lower() if isinstance(timeframe, str) else "weekly" + if normalized_timeframe == "monthly": + result = list_monthly_leaderboard(limit=limit, server_id=server_id, metric=metric) + summary_basis = "closed-matches-calendar-month" + context = "historical-monthly-leaderboard" + else: + normalized_timeframe = "weekly" + result = list_weekly_leaderboard(limit=limit, server_id=server_id, metric=metric) + summary_basis = "closed-matches-calendar-week" + context = "historical-weekly-leaderboard" + is_all_servers = server_id == ALL_SERVERS_SLUG - title_by_metric = { - "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", "data": { - "title": title_by_metric.get(metric, "Ranking semanal por servidor"), - "context": "historical-weekly-leaderboard", + "title": _build_leaderboard_title( + metric=metric, + timeframe=normalized_timeframe, + is_all_servers=is_all_servers, + ), + "context": context, + "timeframe": normalized_timeframe, "metric": metric, - "summary_basis": "closed-matches-calendar-week", + "summary_basis": summary_basis, "window_days": result.get("window_days", 7), "window_start": result["window_start"], "window_end": result["window_end"], @@ -261,6 +266,9 @@ def build_weekly_leaderboard_payload( "current_week_start": result.get("current_week_start"), "current_week_closed_matches": result.get("current_week_closed_matches"), "previous_week_closed_matches": result.get("previous_week_closed_matches"), + "current_month_start": result.get("current_month_start"), + "current_month_closed_matches": result.get("current_month_closed_matches"), + "previous_month_closed_matches": result.get("previous_month_closed_matches"), "sufficient_sample": result.get("sufficient_sample"), "limit": limit, "items": result["items"], @@ -268,6 +276,21 @@ def build_weekly_leaderboard_payload( } +def build_weekly_leaderboard_payload( + *, + limit: int = 10, + server_id: str | None = None, + metric: str = "kills", +) -> dict[str, object]: + """Return one weekly historical leaderboard for the requested metric.""" + return build_historical_leaderboard_payload( + limit=limit, + server_id=server_id, + metric=metric, + timeframe="weekly", + ) + + def build_recent_historical_matches_payload( *, limit: int = 20, @@ -314,52 +337,48 @@ def build_historical_server_summary_snapshot_payload( } -def build_weekly_leaderboard_snapshot_payload( +def build_leaderboard_snapshot_payload( *, limit: int = 10, server_id: str | None = None, metric: str = "kills", + timeframe: str = "weekly", ) -> dict[str, object]: - """Return one precomputed weekly leaderboard snapshot.""" + """Return one precomputed leaderboard snapshot for the requested timeframe.""" + normalized_timeframe = timeframe.strip().lower() if isinstance(timeframe, str) else "weekly" + if normalized_timeframe == "monthly": + snapshot_type = SNAPSHOT_TYPE_MONTHLY_LEADERBOARD + window = DEFAULT_MONTHLY_SNAPSHOT_WINDOW + context = "historical-monthly-leaderboard-snapshot" + else: + normalized_timeframe = "weekly" + snapshot_type = SNAPSHOT_TYPE_WEEKLY_LEADERBOARD + window = DEFAULT_WEEKLY_SNAPSHOT_WINDOW + context = "historical-weekly-leaderboard-snapshot" + snapshot = _get_historical_snapshot_record( server_key=server_id, - snapshot_type=SNAPSHOT_TYPE_WEEKLY_LEADERBOARD, + snapshot_type=snapshot_type, metric=metric, - window=DEFAULT_WEEKLY_SNAPSHOT_WINDOW, + window=window, ) 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 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", "data": { - "title": title_by_metric.get(metric, "Snapshot semanal por servidor"), - "context": "historical-weekly-leaderboard-snapshot", + "title": _build_leaderboard_title( + metric=metric, + timeframe=normalized_timeframe, + is_all_servers=is_all_servers, + snapshot=True, + ), + "context": context, "source": "historical-precomputed-snapshots", "server_slug": server_id, + "timeframe": normalized_timeframe, "metric": metric, "found": snapshot is not None, **_build_historical_snapshot_metadata(snapshot), @@ -377,6 +396,13 @@ def build_weekly_leaderboard_snapshot_payload( "previous_week_closed_matches": ( payload.get("previous_week_closed_matches") if isinstance(payload, dict) else None ), + "current_month_start": payload.get("current_month_start") if isinstance(payload, dict) else None, + "current_month_closed_matches": ( + payload.get("current_month_closed_matches") if isinstance(payload, dict) else None + ), + "previous_month_closed_matches": ( + payload.get("previous_month_closed_matches") if isinstance(payload, dict) else None + ), "sufficient_sample": payload.get("sufficient_sample") if isinstance(payload, dict) else None, "snapshot_limit": payload.get("limit") if isinstance(payload, dict) else None, "limit": limit, @@ -385,6 +411,21 @@ def build_weekly_leaderboard_snapshot_payload( } +def build_weekly_leaderboard_snapshot_payload( + *, + limit: int = 10, + server_id: str | None = None, + metric: str = "kills", +) -> dict[str, object]: + """Return one precomputed weekly leaderboard snapshot.""" + return build_leaderboard_snapshot_payload( + limit=limit, + server_id=server_id, + metric=metric, + timeframe="weekly", + ) + + def build_recent_historical_matches_snapshot_payload( *, limit: int = 20, @@ -499,6 +540,26 @@ def _build_historical_snapshot_metadata(snapshot: dict[str, object] | None) -> d } +def _build_leaderboard_title( + *, + metric: str, + timeframe: str, + is_all_servers: bool, + snapshot: bool = False, +) -> str: + timeframe_label = "mensual" if timeframe == "monthly" else "semanal" + scope_label = "totales" if is_all_servers else "por servidor" + prefix = "Snapshot " if snapshot else "" + title_by_metric = { + "kills": f"{prefix}Top kills {timeframe_label} {scope_label}", + "deaths": f"{prefix}Top muertes {timeframe_label} {scope_label}", + "support": f"{prefix}Top puntos de soporte {timeframe_label} {scope_label}", + "matches_over_100_kills": f"{prefix}Top partidas de 100+ kills {timeframe_label} {scope_label}", + } + fallback_label = f"{prefix}Ranking {timeframe_label} por servidor".strip() + return title_by_metric.get(metric, fallback_label) + + def _enrich_server_items(items: list[dict[str, object]]) -> list[dict[str, object]]: target_index = { target.external_server_id: target diff --git a/backend/app/routes.py b/backend/app/routes.py index 02d4479..2a09508 100644 --- a/backend/app/routes.py +++ b/backend/app/routes.py @@ -10,9 +10,11 @@ from .payloads import ( build_discord_payload, build_error_payload, build_health_payload, + build_historical_leaderboard_payload, build_historical_server_summary_snapshot_payload, build_historical_player_profile_payload, build_historical_server_summary_payload, + build_leaderboard_snapshot_payload, build_recent_historical_matches_snapshot_payload, build_recent_historical_matches_payload, build_server_detail_history_payload, @@ -54,6 +56,25 @@ def resolve_get_payload(path: str) -> tuple[HTTPStatus | None, dict[str, object] server_id = parse_qs(parsed.query).get("server", [None])[0] return HTTPStatus.OK, build_weekly_top_kills_payload(limit=limit, server_id=server_id) + if parsed.path == "/api/historical/leaderboard": + limit = _parse_limit(parsed.query) + if limit is None: + return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid limit parameter") + params = parse_qs(parsed.query) + server_id = params.get("server", [None])[0] + metric = params.get("metric", ["kills"])[0] + timeframe = params.get("timeframe", ["weekly"])[0] + if metric not in {"kills", "deaths", "support", "matches_over_100_kills"}: + return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid metric parameter") + if timeframe not in {"weekly", "monthly"}: + return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid timeframe parameter") + return HTTPStatus.OK, build_historical_leaderboard_payload( + limit=limit, + server_id=server_id, + metric=metric, + timeframe=timeframe, + ) + if parsed.path == "/api/historical/weekly-leaderboard": limit = _parse_limit(parsed.query) if limit is None: @@ -69,6 +90,25 @@ def resolve_get_payload(path: str) -> tuple[HTTPStatus | None, dict[str, object] metric=metric, ) + if parsed.path == "/api/historical/snapshots/leaderboard": + limit = _parse_limit(parsed.query) + if limit is None: + return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid limit parameter") + params = parse_qs(parsed.query) + server_id = params.get("server", [None])[0] + metric = params.get("metric", ["kills"])[0] + timeframe = params.get("timeframe", ["weekly"])[0] + if metric not in {"kills", "deaths", "support", "matches_over_100_kills"}: + return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid metric parameter") + if timeframe not in {"weekly", "monthly"}: + return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid timeframe parameter") + return HTTPStatus.OK, build_leaderboard_snapshot_payload( + limit=limit, + server_id=server_id, + metric=metric, + timeframe=timeframe, + ) + if parsed.path == "/api/historical/snapshots/weekly-leaderboard": limit = _parse_limit(parsed.query) if limit is None: diff --git a/docs/historical-coverage-report.md b/docs/historical-coverage-report.md index f5caff6..5000190 100644 --- a/docs/historical-coverage-report.md +++ b/docs/historical-coverage-report.md @@ -3,6 +3,7 @@ ## Validation Date - 2026-03-21 +- 2026-03-23 ## Scope @@ -18,6 +19,12 @@ Desde `backend/`: python -m app.historical_ingestion bootstrap --max-pages 3 --detail-workers 16 ``` +Bootstrap acotado y reanudable para `comunidad-hispana-03`: + +```powershell +python -m app.historical_ingestion bootstrap --server comunidad-hispana-03 --page-size 10 --max-pages 1 --detail-workers 8 +``` + Verificacion puntual previa de idempotencia sobre la primera pagina ya importada: @@ -60,12 +67,26 @@ larga incluso con paralelismo. - ultima partida persistida: `2026-03-20T21:14:21Z` - rango cubierto: `19.18` dias +### comunidad-hispana-03 + +- matches importados: `33` +- jugadores unicos: `1161` +- filas de estadisticas por jugador: `2547` +- primera partida persistida: `2026-02-24T18:16:11Z` +- ultima partida persistida: `2026-03-08T18:11:52Z` +- rango cubierto: `12.0` dias +- total descubierto en la fuente publica: `11652` matches +- checkpoint actual de bootstrap: `next_page = 2`, `last_completed_page = 1` + ## Interpretation - La base persistida ya supera claramente la ventana semanal en ambos servidores, por lo que la UI historica ya puede distinguir entre "ranking de ultimos 7 dias" y "cobertura total importada" sin fingir que ambos conceptos son lo mismo. +- `comunidad-hispana-03` ya no esta vacio: existe historico real persistido, + snapshots de resumen y partidas recientes, y un checkpoint reanudable para + seguir ampliando cobertura sin repetir desde cero. - El historico local sigue siendo parcial respecto al total reportado por la fuente. Lo importado hoy es suficiente para seguir con semantica y revisiones de UI, pero no representa aun el archivo completo disponible en CRCON. diff --git a/frontend/assets/css/historico.css b/frontend/assets/css/historico.css index bc41e88..642e20f 100644 --- a/frontend/assets/css/historico.css +++ b/frontend/assets/css/historico.css @@ -114,6 +114,10 @@ margin: 0 0 16px; } +.historical-tabs--timeframe { + margin-bottom: 10px; +} + .historical-tab { min-height: 42px; padding: 0 16px; diff --git a/frontend/assets/js/historico.js b/frontend/assets/js/historico.js index 1f68c68..49331ad 100644 --- a/frontend/assets/js/historico.js +++ b/frontend/assets/js/historico.js @@ -25,36 +25,51 @@ const STALE_SNAPSHOT_CACHE_TTL_MS = 30000; const NEGATIVE_SNAPSHOT_CACHE_TTL_MS = 15000; let activeServerSlug = DEFAULT_HISTORICAL_SERVER; let activeLeaderboardMetric; +let activeLeaderboardTimeframe; let activeServerRequestId = 0; let activeLeaderboardRequestId = 0; +const LEADERBOARD_TIMEFRAMES = Object.freeze([ + { + key: "weekly", + label: "Semanal", + shortLabel: "semanal", + }, + { + key: "monthly", + label: "Mensual", + shortLabel: "mensual", + }, +]); const LEADERBOARD_METRICS = Object.freeze([ { key: "kills", - title: "Top kills semanales", + title: "Top kills", valueHeading: "Kills", - emptyMessage: "Sin datos historicos suficientes para mostrar el top de kills semanal.", + emptyMessage: "Sin datos historicos suficientes para mostrar este ranking de kills.", }, { key: "deaths", - title: "Top muertes semanales", + title: "Top muertes", valueHeading: "Muertes", - emptyMessage: "Sin datos historicos suficientes para mostrar el top de muertes semanal.", + emptyMessage: "Sin datos historicos suficientes para mostrar este ranking de muertes.", }, { key: "matches_over_100_kills", title: "Top partidas con 100+ kills", valueHeading: "Partidas 100+", - emptyMessage: "Ningun jugador ha registrado partidas de 100+ kills en esta ventana semanal.", + emptyMessage: "Ningun jugador ha registrado partidas de 100+ kills en esta ventana.", }, { key: "support", - title: "Top puntos de soporte semanales", + title: "Top puntos de soporte", valueHeading: "Soporte", - emptyMessage: "Sin datos historicos suficientes para mostrar el top de soporte semanal.", + emptyMessage: "Sin datos historicos suficientes para mostrar este ranking de soporte.", }, ]); const DEFAULT_LEADERBOARD_METRIC = LEADERBOARD_METRICS[0].key; +const DEFAULT_LEADERBOARD_TIMEFRAME = LEADERBOARD_TIMEFRAMES[0].key; activeLeaderboardMetric = DEFAULT_LEADERBOARD_METRIC; +activeLeaderboardTimeframe = DEFAULT_LEADERBOARD_TIMEFRAME; document.addEventListener("DOMContentLoaded", () => { const backendBaseUrl = @@ -62,6 +77,9 @@ document.addEventListener("DOMContentLoaded", () => { const selectorButtons = Array.from( document.querySelectorAll("[data-server-slug]"), ); + const leaderboardTimeframeButtons = Array.from( + document.querySelectorAll("[data-leaderboard-timeframe]"), + ); const leaderboardTabButtons = Array.from( document.querySelectorAll("[data-leaderboard-metric]"), ); @@ -90,6 +108,9 @@ document.addEventListener("DOMContentLoaded", () => { const params = new URLSearchParams(window.location.search); activeServerSlug = normalizeServerSlug(params.get("server")); activeLeaderboardMetric = normalizeLeaderboardMetric(params.get("metric")); + activeLeaderboardTimeframe = normalizeLeaderboardTimeframe( + params.get("timeframe"), + ); const summaryCache = new Map(); const recentMatchesCache = new Map(); @@ -112,12 +133,12 @@ document.addEventListener("DOMContentLoaded", () => { `${backendBaseUrl}/api/historical/snapshots/recent-matches?server=${encodeURIComponent(serverSlug)}&limit=6`, ); - const getLeaderboardSnapshot = (serverSlug, metricKey) => + const getLeaderboardSnapshot = (serverSlug, timeframeKey, metricKey) => getCachedJson( leaderboardCache, pendingRequestCache, - buildLeaderboardSnapshotKey(serverSlug, metricKey), - `${backendBaseUrl}/api/historical/snapshots/weekly-leaderboard?server=${encodeURIComponent(serverSlug)}&metric=${encodeURIComponent(metricKey)}&limit=10`, + buildLeaderboardSnapshotKey(serverSlug, timeframeKey, metricKey), + `${backendBaseUrl}/api/historical/snapshots/leaderboard?server=${encodeURIComponent(serverSlug)}&timeframe=${encodeURIComponent(timeframeKey)}&metric=${encodeURIComponent(metricKey)}&limit=10`, ); const refreshServerContent = async () => { @@ -126,22 +147,32 @@ document.addEventListener("DOMContentLoaded", () => { activeServerRequestId = requestId; activeLeaderboardRequestId = leaderboardRequestId; const activeMetricConfig = getLeaderboardMetricConfig(activeLeaderboardMetric); + const activeTimeframeConfig = getLeaderboardTimeframeConfig( + activeLeaderboardTimeframe, + ); const activeServerLabel = getHistoricalServerLabel(activeServerSlug); syncActiveButtons(selectorButtons, activeServerSlug); + syncLeaderboardTimeframes( + leaderboardTimeframeButtons, + activeLeaderboardTimeframe, + ); syncLeaderboardTabs(leaderboardTabButtons, activeLeaderboardMetric); weeklyTitleNode.textContent = buildLeaderboardTitle( activeMetricConfig, activeServerSlug, + activeLeaderboardTimeframe, ); weeklyValueHeadingNode.textContent = activeMetricConfig.valueHeading; setRangeBadge(rangeNode, "Cargando rango temporal", false); 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 alcance activo..."; - setSnapshotMeta(weeklySnapshotMetaNode, "Preparando snapshot semanal..."); + weeklyWindowNoteNode.textContent = "Cargando snapshot del ranking activo..."; + setSnapshotMeta( + weeklySnapshotMetaNode, + `Preparando snapshot ${activeTimeframeConfig.shortLabel}...`, + ); recentListNode.innerHTML = ""; recentNoteNode.textContent = buildRecentMatchesNote(activeServerSlug); setState(recentStateNode, "Cargando partidas recientes..."); @@ -163,7 +194,11 @@ document.addEventListener("DOMContentLoaded", () => { const cachedLeaderboardPayload = readCachedPayload( leaderboardCache, - buildLeaderboardSnapshotKey(activeServerSlug, activeLeaderboardMetric), + buildLeaderboardSnapshotKey( + activeServerSlug, + activeLeaderboardTimeframe, + activeLeaderboardMetric, + ), ); if (cachedLeaderboardPayload) { hydrateWeeklyLeaderboard( @@ -176,9 +211,13 @@ document.addEventListener("DOMContentLoaded", () => { weeklyWindowNoteNode, weeklySnapshotMetaNode, activeMetricConfig, + activeLeaderboardTimeframe, ); } else { - setState(weeklyStateNode, "Cargando ranking semanal..."); + setState( + weeklyStateNode, + `Cargando ranking ${activeTimeframeConfig.shortLabel}...`, + ); weeklyTableNode.hidden = true; } @@ -196,9 +235,17 @@ document.addEventListener("DOMContentLoaded", () => { } const targetServerSlug = activeServerSlug; + const targetTimeframe = activeLeaderboardTimeframe; const targetMetric = activeLeaderboardMetric; void settlePromise(getSummarySnapshot(targetServerSlug)).then((summaryResult) => { - if (!isActiveServerRequest(requestId, targetServerSlug, targetMetric)) { + if ( + !isActiveServerRequest( + requestId, + targetServerSlug, + targetTimeframe, + targetMetric, + ) + ) { return; } @@ -212,7 +259,14 @@ document.addEventListener("DOMContentLoaded", () => { }); void settlePromise(getRecentMatchesSnapshot(targetServerSlug)).then((recentMatchesResult) => { - if (!isActiveServerRequest(requestId, targetServerSlug, targetMetric)) { + if ( + !isActiveServerRequest( + requestId, + targetServerSlug, + targetTimeframe, + targetMetric, + ) + ) { return; } @@ -225,13 +279,14 @@ document.addEventListener("DOMContentLoaded", () => { }); void settlePromise( - getLeaderboardSnapshot(targetServerSlug, targetMetric), + getLeaderboardSnapshot(targetServerSlug, targetTimeframe, targetMetric), ).then((leaderboardResult) => { if ( !isActiveLeaderboardRequest( requestId, leaderboardRequestId, targetServerSlug, + targetTimeframe, targetMetric, ) ) { @@ -248,6 +303,7 @@ document.addEventListener("DOMContentLoaded", () => { weeklyWindowNoteNode, weeklySnapshotMetaNode, activeMetricConfig, + targetTimeframe, ); }); }; @@ -256,19 +312,32 @@ document.addEventListener("DOMContentLoaded", () => { const requestId = activeLeaderboardRequestId + 1; activeLeaderboardRequestId = requestId; const metricConfig = getLeaderboardMetricConfig(activeLeaderboardMetric); + const timeframeConfig = getLeaderboardTimeframeConfig( + activeLeaderboardTimeframe, + ); const targetServerSlug = activeServerSlug; + const targetTimeframe = activeLeaderboardTimeframe; const targetMetric = activeLeaderboardMetric; + syncLeaderboardTimeframes( + leaderboardTimeframeButtons, + activeLeaderboardTimeframe, + ); syncLeaderboardTabs(leaderboardTabButtons, activeLeaderboardMetric); weeklyTitleNode.textContent = buildLeaderboardTitle( metricConfig, activeServerSlug, + activeLeaderboardTimeframe, ); weeklyValueHeadingNode.textContent = metricConfig.valueHeading; const cachedPayload = readCachedPayload( leaderboardCache, - buildLeaderboardSnapshotKey(targetServerSlug, targetMetric), + buildLeaderboardSnapshotKey( + targetServerSlug, + targetTimeframe, + targetMetric, + ), ); if (cachedPayload) { hydrateWeeklyLeaderboard( @@ -281,23 +350,30 @@ document.addEventListener("DOMContentLoaded", () => { weeklyWindowNoteNode, weeklySnapshotMetaNode, metricConfig, + targetTimeframe, ); return; } - weeklyWindowNoteNode.textContent = - "Cargando snapshot semanal del alcance activo..."; - setSnapshotMeta(weeklySnapshotMetaNode, "Cargando snapshot semanal..."); - setState(weeklyStateNode, "Cargando ranking semanal..."); + weeklyWindowNoteNode.textContent = "Cargando snapshot del ranking activo..."; + setSnapshotMeta( + weeklySnapshotMetaNode, + `Cargando snapshot ${timeframeConfig.shortLabel}...`, + ); + setState( + weeklyStateNode, + `Cargando ranking ${timeframeConfig.shortLabel}...`, + ); weeklyTableNode.hidden = true; const leaderboardResult = await settlePromise( - getLeaderboardSnapshot(targetServerSlug, targetMetric), + getLeaderboardSnapshot(targetServerSlug, targetTimeframe, targetMetric), ); if ( requestId !== activeLeaderboardRequestId || targetServerSlug !== activeServerSlug || + targetTimeframe !== activeLeaderboardTimeframe || targetMetric !== activeLeaderboardMetric ) { return; @@ -313,6 +389,7 @@ document.addEventListener("DOMContentLoaded", () => { weeklyWindowNoteNode, weeklySnapshotMetaNode, metricConfig, + targetTimeframe, ); }; @@ -325,12 +402,31 @@ document.addEventListener("DOMContentLoaded", () => { activeServerSlug = nextServerSlug; params.set("server", activeServerSlug); + params.set("timeframe", activeLeaderboardTimeframe); params.set("metric", activeLeaderboardMetric); window.history.replaceState({}, "", `?${params.toString()}`); void refreshServerContent(); }); }); + leaderboardTimeframeButtons.forEach((button) => { + button.addEventListener("click", () => { + const nextTimeframe = normalizeLeaderboardTimeframe( + button.dataset.leaderboardTimeframe, + ); + if (nextTimeframe === activeLeaderboardTimeframe) { + return; + } + + activeLeaderboardTimeframe = nextTimeframe; + params.set("server", activeServerSlug); + params.set("timeframe", activeLeaderboardTimeframe); + params.set("metric", activeLeaderboardMetric); + window.history.replaceState({}, "", `?${params.toString()}`); + void refreshLeaderboardContent(); + }); + }); + leaderboardTabButtons.forEach((button) => { button.addEventListener("click", () => { const nextMetric = normalizeLeaderboardMetric(button.dataset.leaderboardMetric); @@ -340,6 +436,7 @@ document.addEventListener("DOMContentLoaded", () => { activeLeaderboardMetric = nextMetric; params.set("server", activeServerSlug); + params.set("timeframe", activeLeaderboardTimeframe); params.set("metric", activeLeaderboardMetric); window.history.replaceState({}, "", `?${params.toString()}`); void refreshLeaderboardContent(); @@ -349,10 +446,11 @@ document.addEventListener("DOMContentLoaded", () => { void refreshServerContent(); }); -function isActiveServerRequest(requestId, serverSlug, metricKey) { +function isActiveServerRequest(requestId, serverSlug, timeframeKey, metricKey) { return ( requestId === activeServerRequestId && serverSlug === activeServerSlug && + timeframeKey === activeLeaderboardTimeframe && metricKey === activeLeaderboardMetric ); } @@ -361,10 +459,11 @@ function isActiveLeaderboardRequest( serverRequestId, leaderboardRequestId, serverSlug, + timeframeKey, metricKey, ) { return ( - isActiveServerRequest(serverRequestId, serverSlug, metricKey) && + isActiveServerRequest(serverRequestId, serverSlug, timeframeKey, metricKey) && leaderboardRequestId === activeLeaderboardRequestId ); } @@ -448,35 +547,63 @@ function hydrateWeeklyLeaderboard( noteNode, snapshotMetaNode, metricConfig, + timeframeKey, ) { const targetServerSlug = result.value?.data?.server_slug || activeServerSlug; + const resolvedTimeframeKey = result.value?.data?.timeframe || timeframeKey; valueHeadingNode.textContent = metricConfig.valueHeading; if (result.status !== "fulfilled") { - titleNode.textContent = metricConfig.title; + titleNode.textContent = buildLeaderboardTitle( + metricConfig, + targetServerSlug, + resolvedTimeframeKey, + ); noteNode.textContent = - "No se pudo leer el snapshot semanal precalculado para esta metrica."; - setSnapshotMeta(snapshotMetaNode, "Error al leer el snapshot semanal."); - setState(stateNode, "No se pudo cargar el ranking semanal.", true); + "No se pudo leer el snapshot precalculado para esta metrica."; + setSnapshotMeta(snapshotMetaNode, "Error al leer el snapshot del ranking."); + setState( + stateNode, + `No se pudo cargar el ranking ${getLeaderboardTimeframeConfig(resolvedTimeframeKey).shortLabel}.`, + true, + ); tableNode.hidden = true; return; } const payload = result.value?.data; - titleNode.textContent = buildLeaderboardTitle(metricConfig, payload?.server_slug); + titleNode.textContent = buildLeaderboardTitle( + metricConfig, + payload?.server_slug, + payload?.timeframe || resolvedTimeframeKey, + ); noteNode.textContent = buildWeeklyWindowNote(payload); setSnapshotMeta( snapshotMetaNode, - buildSnapshotMetaText(payload, "Snapshot semanal pendiente de generacion."), + buildSnapshotMetaText(payload, "Snapshot del ranking pendiente de generacion."), ); if (!payload?.found) { - setState(stateNode, buildLeaderboardEmptyMessage(metricConfig, targetServerSlug)); + setState( + stateNode, + buildLeaderboardEmptyMessage( + metricConfig, + targetServerSlug, + payload?.timeframe || resolvedTimeframeKey, + ), + ); tableNode.hidden = true; return; } const items = payload?.items; if (!Array.isArray(items) || items.length === 0) { - setState(stateNode, buildLeaderboardEmptyMessage(metricConfig, targetServerSlug)); + setState( + stateNode, + buildLeaderboardEmptyMessage( + metricConfig, + targetServerSlug, + payload?.timeframe || resolvedTimeframeKey, + ), + ); tableNode.hidden = true; return; } @@ -612,6 +739,14 @@ function syncLeaderboardTabs(buttons, activeMetric) { }); } +function syncLeaderboardTimeframes(buttons, activeTimeframe) { + buttons.forEach((button) => { + const isActive = button.dataset.leaderboardTimeframe === activeTimeframe; + button.classList.toggle("is-active", isActive); + button.setAttribute("aria-selected", String(isActive)); + }); +} + function normalizeServerSlug(rawValue) { const normalized = typeof rawValue === "string" ? rawValue.trim() : ""; if (HISTORICAL_SERVER_SLUGS.includes(normalized)) { @@ -637,6 +772,15 @@ function normalizeLeaderboardMetric(rawValue) { return DEFAULT_LEADERBOARD_METRIC; } +function normalizeLeaderboardTimeframe(rawValue) { + const normalized = typeof rawValue === "string" ? rawValue.trim() : ""; + if (LEADERBOARD_TIMEFRAMES.some((timeframe) => timeframe.key === normalized)) { + return normalized; + } + + return DEFAULT_LEADERBOARD_TIMEFRAME; +} + function getLeaderboardMetricConfig(metricKey) { return ( LEADERBOARD_METRICS.find((metric) => metric.key === metricKey) || @@ -644,6 +788,13 @@ function getLeaderboardMetricConfig(metricKey) { ); } +function getLeaderboardTimeframeConfig(timeframeKey) { + return ( + LEADERBOARD_TIMEFRAMES.find((timeframe) => timeframe.key === timeframeKey) || + LEADERBOARD_TIMEFRAMES[0] + ); +} + function buildSummarySnapshotKey(serverSlug) { return `summary:${serverSlug}`; } @@ -652,8 +803,8 @@ function buildRecentMatchesSnapshotKey(serverSlug) { return `recent:${serverSlug}`; } -function buildLeaderboardSnapshotKey(serverSlug, metricKey) { - return `leaderboard:${serverSlug}:${metricKey}`; +function buildLeaderboardSnapshotKey(serverSlug, timeframeKey, metricKey) { + return `leaderboard:${serverSlug}:${timeframeKey}:${metricKey}`; } function buildRangeLabel(start, end) { @@ -668,9 +819,7 @@ function buildCoverageBadgeLabel(coverage, timeRange, serverSlug) { const rangeStart = coverage?.first_match_at || timeRange?.start; const rangeEnd = coverage?.last_match_at || timeRange?.end; if (!rangeStart && !rangeEnd) { - return serverSlug === "comunidad-hispana-03" - ? "Pendiente de historico" - : "Sin cobertura registrada"; + return "Sin cobertura registrada"; } if (coverage?.status === "under-week") { return "Cobertura inicial"; @@ -693,9 +842,6 @@ function buildCoveragePeriodLabel(coverage, timeRange, serverSlug) { if (end) { return `Hasta ${formatDateOnly(end)}`; } - if (serverSlug === "comunidad-hispana-03") { - return "Pendiente de bootstrap historico"; - } return "Sin cobertura registrada"; } @@ -706,9 +852,7 @@ function buildSummaryNote(summaryBasis, weeklyWindowDays, coverage, serverSlug) : "el historico persistido disponible"; const status = coverage?.status; void weeklyWindowDays; - if (serverSlug === "comunidad-hispana-03" && status === "empty") { - return "Comunidad Hispana #03 todavia no tiene historico registrado. Este bloque se activara cuando se ejecute su primer bootstrap."; - } + void serverSlug; if (status === "under-week") { return `Este bloque resume ${basisLabel}. La cobertura registrada todavia es inicial y puede crecer en los proximos dias.`; } @@ -720,36 +864,38 @@ function buildSummaryNote(summaryBasis, weeklyWindowDays, coverage, serverSlug) function buildWeeklyWindowNote(payload) { if (!payload?.found) { - if ((payload?.server_slug || activeServerSlug) === "comunidad-hispana-03") { - return "El ranking semanal aparecera cuando Comunidad Hispana #03 tenga su primer bootstrap historico."; - } - return "No existe un snapshot semanal listo para esta metrica en el alcance activo."; + const timeframeLabel = getLeaderboardTimeframeConfig( + payload?.timeframe || activeLeaderboardTimeframe, + ).shortLabel; + return `No existe un snapshot ${timeframeLabel} listo para esta metrica en el alcance activo.`; } const start = formatTimestamp(payload?.window_start); const end = formatTimestamp(payload?.window_end); - const windowLabel = payload?.window_label || "Semana activa"; + const windowLabel = + payload?.window_label || + (payload?.timeframe === "monthly" ? "Mes activo" : "Semana activa"); if (payload?.uses_fallback) { - const currentWeekMatches = + const currentPeriodMatches = payload?.current_week_closed_matches ?? + payload?.current_month_closed_matches ?? payload?.sufficient_sample?.current_week_closed_matches ?? + payload?.sufficient_sample?.current_month_closed_matches ?? 0; - return `${windowLabel}: ${start} a ${end}. Se muestra la ultima semana cerrada porque la actual todavia solo suma ${formatNumber(currentWeekMatches)} cierres.`; + return `${windowLabel}: ${start} a ${end}. Se muestra el ultimo periodo cerrado porque el actual todavia solo suma ${formatNumber(currentPeriodMatches)} cierres.`; } return `${windowLabel}: ${start} a ${end}.`; } -function buildLeaderboardTitle(metricConfig, serverSlug) { - return `${metricConfig.title} - ${getHistoricalServerLabel(serverSlug)}`; +function buildLeaderboardTitle(metricConfig, serverSlug, timeframeKey) { + const timeframeLabel = getLeaderboardTimeframeConfig(timeframeKey).label; + return `${metricConfig.title} ${timeframeLabel} - ${getHistoricalServerLabel(serverSlug)}`; } function buildRecentMatchesNote(serverSlug) { if (serverSlug === "all-servers") { return "Lista de cierres ya registrados para los servidores con historico disponible."; } - if (serverSlug === "comunidad-hispana-03") { - return "Este bloque quedara activo cuando Comunidad Hispana #03 tenga su primer registro historico."; - } return `Lista de cierres ya registrados para ${getHistoricalServerLabel(serverSlug)}.`; } @@ -935,24 +1081,14 @@ function escapeHtml(value) { .replaceAll("'", "'"); } -function buildLeaderboardEmptyMessage(metricConfig, serverSlug) { - if (serverSlug === "comunidad-hispana-03") { - return "Comunidad Hispana #03 todavia no tiene historico registrado para mostrar este ranking."; - } - return metricConfig.emptyMessage; +function buildLeaderboardEmptyMessage(metricConfig, serverSlug, timeframeKey) { + void serverSlug; + const timeframeLabel = getLeaderboardTimeframeConfig(timeframeKey).shortLabel; + return metricConfig.emptyMessage.replace("esta ventana", `esta ventana ${timeframeLabel}`); } function getHistoricalEmptyState(serverSlug) { - if (serverSlug === "comunidad-hispana-03") { - return { - rangeLabel: "Pendiente de historico", - summaryMessage: "Sin historico registrado todavia", - summaryNote: - "Comunidad Hispana #03 sigue pendiente de bootstrap historico. Cuando se registren sus primeras partidas, aqui apareceran su cobertura y sus totales.", - recentMessage: - "Comunidad Hispana #03 todavia no tiene partidas historicas registradas.", - }; - } + void serverSlug; return { rangeLabel: "Sin cobertura registrada", diff --git a/frontend/historico.html b/frontend/historico.html index 600d2ce..ea5afe8 100644 --- a/frontend/historico.html +++ b/frontend/historico.html @@ -111,21 +111,45 @@
Ranking semanal
-Rankings historicos
+- Cargando ventana semanal... + Cargando rango del ranking...