diff --git a/ai/tasks/done/TASK-045-historical-ui-migrate-to-snapshots.md b/ai/tasks/done/TASK-045-historical-ui-migrate-to-snapshots.md new file mode 100644 index 0000000..30ac7ac --- /dev/null +++ b/ai/tasks/done/TASK-045-historical-ui-migrate-to-snapshots.md @@ -0,0 +1,76 @@ +# TASK-045-historical-ui-migrate-to-snapshots + +## Goal +Migrar la pagina historica para que lea snapshots precalculados de resumen, tops y partidas recientes, mejorando tiempos de carga y evitando esperas innecesarias al abrir la pagina o cambiar de pestana. + +## Context +La UI historica ya existe, pero el objetivo ahora es que no dependa de consultas pesadas en tiempo real. Debe leer snapshots ya preparados y actualizados en segundo plano, mostrando datos rapidos y estables. + +## Steps +1. Revisar la API de snapshots ya implementada. +2. Cambiar la UI historica para que: + - resumen + - tabs de tops + - partidas recientes + se carguen desde snapshots y no desde agregados costosos on-demand. +3. Mostrar de forma clara cuando se genero el snapshot. +4. Mantener estados de loading, empty y error, pero reducir la espera perceptible. +5. Asegurar que cambiar entre pestanas de tops sea rapido y estable. +6. Mantener coherencia visual con el resto de la web. +7. No crear paginas nuevas en esta task. +8. Al completar la implementacion: + - 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 + +## Expected Files to Modify +- frontend/historico.html +- frontend/assets/js/historico.js +- frontend/assets/css/historico.css +- opcionalmente backend docs minimas si cambia el flujo de consumo + +## Constraints +- No romper la UI historica existente. +- No introducir frameworks nuevos. +- No hacer cambios destructivos. +- Mantener el trabajo centrado en rendimiento percibido y consumo de snapshots. + +## Validation +- La UI historica carga resumen, tops y partidas recientes desde snapshots. +- Cambiar de pestana entre tops es rapido. +- El usuario ve una referencia clara de actualizacion. +- La pagina reduce la dependencia de calculos on-demand pesados. +- 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 lineas cambiadas. + +## Outcome +- `frontend/assets/js/historico.js` deja de depender de agregados historicos on-demand y pasa a leer snapshots precalculados para resumen, ranking semanal y partidas recientes. +- `frontend/assets/js/historico.js` incorpora cache por servidor y metrica para que cambiar de pestana entre tops reutilice payloads ya leidos y reduzca la espera perceptible. +- `frontend/historico.html` y `frontend/assets/css/historico.css` muestran una referencia explicita de `generated_at` y del rango fuente de cada snapshot en las tres secciones principales. +- `backend/app/routes.py` y `backend/app/payloads.py` anaden la capa minima de lectura `/api/historical/snapshots/*` necesaria para servir snapshots precalculados sin recalcular agregados pesados. +- `backend/README.md` documenta los nuevos endpoints de snapshots y la metadata operativa que exponen. + +## Validation Result +- Validado con `node --check frontend/assets/js/historico.js`. +- Validado con `python -m py_compile backend/app/routes.py backend/app/payloads.py`. +- Validado con `resolve_get_payload(...)` para: + - `/api/historical/snapshots/server-summary` + - `/api/historical/snapshots/weekly-leaderboard` + - `/api/historical/snapshots/recent-matches` +- Validado con builders Python: los nuevos payloads responden de forma estable incluso cuando todavia no existen snapshots persistidos y devuelven `found: False` con `items: []` o `item: None`. +- Revisado en diff: el alcance queda limitado a `backend/README.md`, `backend/app/routes.py`, `backend/app/payloads.py`, `frontend/historico.html`, `frontend/assets/css/historico.css`, `frontend/assets/js/historico.js` y este archivo de task. + +## Decision Notes +- La task estaba bloqueada por una dependencia no resuelta: la UI debia migrar a snapshots, pero la API ligera de lectura descrita en la task previa no estaba presente en `routes.py` ni en `payloads.py` dentro de este worktree. Se implemento solo la capa minima necesaria para completar la migracion sin ampliar el alcance a nueva UI ni a nuevos calculos backend. +- Se mantuvieron los endpoints historicos legacy para no romper compatibilidad mientras la pagina historica migra al contrato basado en snapshots. diff --git a/backend/README.md b/backend/README.md index 56a487b..2d06e0e 100644 --- a/backend/README.md +++ b/backend/README.md @@ -126,6 +126,9 @@ normaliza espacios y barras finales para mantener la comparacion con el header - `GET /api/historical/weekly-leaderboard?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/recent-matches?limit=6&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 @@ -461,6 +464,9 @@ La capa historica propia expone: - `/api/historical/weekly-leaderboard` - `/api/historical/recent-matches` - `/api/historical/server-summary` +- `/api/historical/snapshots/server-summary` +- `/api/historical/snapshots/weekly-leaderboard` +- `/api/historical/snapshots/recent-matches` - `/api/historical/player-profile` Parametros opcionales: @@ -493,6 +499,23 @@ conteo de jugadores. `server-summary` agrega volumen historico, jugadores unicos, kills, mapas dominantes y rango temporal cubierto. `player-profile` deja lista la base de consulta agregada por jugador para futuras vistas. +La familia `/api/historical/snapshots/*` lee directamente la tabla +`historical_precomputed_snapshots` y evita recalcular agregados pesados en cada +request. Estos endpoints devuelven payloads ligeros listos para frontend con: + +- `generated_at` +- `source_range_start` +- `source_range_end` +- `is_stale` +- `found` + +`/api/historical/snapshots/server-summary` devuelve `item` con el resumen del +servidor. `/api/historical/snapshots/weekly-leaderboard` devuelve `items` ya +precalculados para una metrica semanal y acepta `limit` para recortar el +payload ya persistido sin recalcularlo. `/api/historical/snapshots/recent-matches` +devuelve `items` de cierres recientes ya preparados y tambien acepta `limit` +para servir solo una parte del snapshot persistido. + ## Ingesta historica CRCON La ingesta historica no usa A2S ni scraping del HTML de `/games`. Consume la diff --git a/backend/app/payloads.py b/backend/app/payloads.py index c148d9e..79f971b 100644 --- a/backend/app/payloads.py +++ b/backend/app/payloads.py @@ -6,6 +6,14 @@ from datetime import datetime, timezone 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_SNAPSHOT_WINDOW, + DEFAULT_WEEKLY_SNAPSHOT_WINDOW, + SNAPSHOT_TYPE_RECENT_MATCHES, + SNAPSHOT_TYPE_SERVER_SUMMARY, + SNAPSHOT_TYPE_WEEKLY_LEADERBOARD, +) from .historical_storage import ( get_historical_player_profile, list_historical_server_summaries, @@ -262,6 +270,104 @@ def build_recent_historical_matches_payload( } +def build_historical_server_summary_snapshot_payload( + *, + server_slug: str | None = None, +) -> dict[str, object]: + """Return one precomputed summary snapshot without recalculating aggregates.""" + snapshot = _get_historical_snapshot_record( + server_key=server_slug, + snapshot_type=SNAPSHOT_TYPE_SERVER_SUMMARY, + window=DEFAULT_SNAPSHOT_WINDOW, + ) + payload = snapshot.get("payload") if snapshot else {} + item = payload.get("item") if isinstance(payload, dict) else None + return { + "status": "ok", + "data": { + "title": "Snapshot historico de resumen por servidor", + "context": "historical-server-summary-snapshot", + "source": "historical-precomputed-snapshots", + "server_slug": server_slug, + "found": snapshot is not None and isinstance(item, dict), + **_build_historical_snapshot_metadata(snapshot), + "item": item if isinstance(item, dict) else None, + }, + } + + +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.""" + snapshot = _get_historical_snapshot_record( + server_key=server_id, + snapshot_type=SNAPSHOT_TYPE_WEEKLY_LEADERBOARD, + metric=metric, + window=DEFAULT_WEEKLY_SNAPSHOT_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 [] + title_by_metric = { + "kills": "Snapshot semanal de top kills por servidor", + "deaths": "Snapshot semanal de top muertes por servidor", + "support": "Snapshot semanal de top soporte por servidor", + "matches_over_100_kills": "Snapshot semanal de partidas 100+ kills por servidor", + } + return { + "status": "ok", + "data": { + "title": title_by_metric.get(metric, "Snapshot semanal por servidor"), + "context": "historical-weekly-leaderboard-snapshot", + "source": "historical-precomputed-snapshots", + "server_slug": server_id, + "metric": metric, + "found": snapshot is not None, + **_build_historical_snapshot_metadata(snapshot), + "window_days": payload.get("window_days") if isinstance(payload, dict) else 7, + "window_start": payload.get("window_start") if isinstance(payload, dict) else None, + "window_end": payload.get("window_end") if isinstance(payload, dict) else None, + "snapshot_limit": payload.get("limit") if isinstance(payload, dict) else None, + "limit": limit, + "items": sliced_items, + }, + } + + +def build_recent_historical_matches_snapshot_payload( + *, + limit: int = 20, + server_slug: str | None = None, +) -> dict[str, object]: + """Return one precomputed recent-matches snapshot.""" + snapshot = _get_historical_snapshot_record( + server_key=server_slug, + snapshot_type=SNAPSHOT_TYPE_RECENT_MATCHES, + window=DEFAULT_SNAPSHOT_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 [] + return { + "status": "ok", + "data": { + "title": "Snapshot historico de partidas recientes por servidor", + "context": "historical-recent-matches-snapshot", + "source": "historical-precomputed-snapshots", + "server_slug": server_slug, + "found": snapshot is not None, + **_build_historical_snapshot_metadata(snapshot), + "snapshot_limit": payload.get("limit") if isinstance(payload, dict) else None, + "limit": limit, + "items": sliced_items, + }, + } + + def build_historical_server_summary_payload( *, server_slug: str | None = None, @@ -298,6 +404,39 @@ def build_historical_player_profile_payload(player_id: str) -> dict[str, object] } +def _get_historical_snapshot_record( + *, + server_key: str | None, + snapshot_type: str, + metric: str | None = None, + window: str | None = None, +) -> dict[str, object] | None: + if not server_key: + return None + return get_historical_snapshot( + server_key=server_key, + snapshot_type=snapshot_type, + metric=metric, + window=window, + ) + + +def _build_historical_snapshot_metadata(snapshot: dict[str, object] | None) -> dict[str, object]: + if snapshot is None: + return { + "generated_at": None, + "source_range_start": None, + "source_range_end": None, + "is_stale": True, + } + return { + "generated_at": snapshot.get("generated_at"), + "source_range_start": snapshot.get("source_range_start"), + "source_range_end": snapshot.get("source_range_end"), + "is_stale": bool(snapshot.get("is_stale", False)), + } + + 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 552364e..02d4479 100644 --- a/backend/app/routes.py +++ b/backend/app/routes.py @@ -10,14 +10,17 @@ from .payloads import ( build_discord_payload, build_error_payload, build_health_payload, + build_historical_server_summary_snapshot_payload, build_historical_player_profile_payload, build_historical_server_summary_payload, + build_recent_historical_matches_snapshot_payload, build_recent_historical_matches_payload, build_server_detail_history_payload, build_server_history_payload, build_server_latest_payload, build_servers_payload, build_trailer_payload, + build_weekly_leaderboard_snapshot_payload, build_weekly_leaderboard_payload, build_weekly_top_kills_payload, ) @@ -66,6 +69,21 @@ def resolve_get_payload(path: str) -> tuple[HTTPStatus | None, dict[str, object] metric=metric, ) + if parsed.path == "/api/historical/snapshots/weekly-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] + if metric not in {"kills", "deaths", "support", "matches_over_100_kills"}: + return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid metric parameter") + return HTTPStatus.OK, build_weekly_leaderboard_snapshot_payload( + limit=limit, + server_id=server_id, + metric=metric, + ) + if parsed.path == "/api/historical/recent-matches": limit = _parse_limit(parsed.query) if limit is None: @@ -76,10 +94,26 @@ def resolve_get_payload(path: str) -> tuple[HTTPStatus | None, dict[str, object] server_slug=server_slug, ) + if parsed.path == "/api/historical/snapshots/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_snapshot_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/snapshots/server-summary": + server_slug = parse_qs(parsed.query).get("server", [None])[0] + return HTTPStatus.OK, build_historical_server_summary_snapshot_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: diff --git a/frontend/assets/css/historico.css b/frontend/assets/css/historico.css index a2bdfb6..bc41e88 100644 --- a/frontend/assets/css/historico.css +++ b/frontend/assets/css/historico.css @@ -66,6 +66,13 @@ line-height: 1.55; } +.historical-snapshot-meta { + margin: 8px 0 0; + color: var(--text-soft); + font-size: 0.86rem; + line-height: 1.5; +} + .historical-selector { display: flex; flex-wrap: wrap; diff --git a/frontend/assets/js/historico.js b/frontend/assets/js/historico.js index 055ec9f..70644a8 100644 --- a/frontend/assets/js/historico.js +++ b/frontend/assets/js/historico.js @@ -43,57 +43,136 @@ document.addEventListener("DOMContentLoaded", () => { const summaryNode = document.getElementById("historical-summary"); const rangeNode = document.getElementById("historical-range"); const summaryNoteNode = document.getElementById("historical-summary-note"); + const summarySnapshotMetaNode = document.getElementById( + "historical-summary-snapshot-meta", + ); const weeklyTitleNode = document.getElementById("weekly-ranking-title"); const weeklyStateNode = document.getElementById("weekly-leaderboard-state"); const weeklyTableNode = document.getElementById("weekly-leaderboard-table"); const weeklyBodyNode = document.getElementById("weekly-leaderboard-body"); const weeklyValueHeadingNode = document.getElementById("weekly-leaderboard-value-heading"); const weeklyWindowNoteNode = document.getElementById("weekly-window-note"); + const weeklySnapshotMetaNode = document.getElementById( + "weekly-leaderboard-snapshot-meta", + ); const recentStateNode = document.getElementById("recent-matches-state"); const recentListNode = document.getElementById("recent-matches-list"); + const recentSnapshotMetaNode = document.getElementById( + "recent-matches-snapshot-meta", + ); const params = new URLSearchParams(window.location.search); let activeServerSlug = normalizeServerSlug(params.get("server")); let activeLeaderboardMetric = normalizeLeaderboardMetric(params.get("metric")); - let refreshRequestId = 0; + let activeServerRequestId = 0; + let activeLeaderboardRequestId = 0; - const refreshHistoricalView = async () => { - const requestId = refreshRequestId + 1; - refreshRequestId = requestId; + const summaryCache = new Map(); + const recentMatchesCache = new Map(); + const leaderboardCache = new Map(); + const pendingRequestCache = new Map(); + + const getSummarySnapshot = (serverSlug) => + getCachedJson( + summaryCache, + pendingRequestCache, + buildSummarySnapshotKey(serverSlug), + `${backendBaseUrl}/api/historical/snapshots/server-summary?server=${encodeURIComponent(serverSlug)}`, + ); + + const getRecentMatchesSnapshot = (serverSlug) => + getCachedJson( + recentMatchesCache, + pendingRequestCache, + buildRecentMatchesSnapshotKey(serverSlug), + `${backendBaseUrl}/api/historical/snapshots/recent-matches?server=${encodeURIComponent(serverSlug)}&limit=6`, + ); + + const getLeaderboardSnapshot = (serverSlug, metricKey) => + getCachedJson( + leaderboardCache, + pendingRequestCache, + buildLeaderboardSnapshotKey(serverSlug, metricKey), + `${backendBaseUrl}/api/historical/snapshots/weekly-leaderboard?server=${encodeURIComponent(serverSlug)}&metric=${encodeURIComponent(metricKey)}&limit=10`, + ); + + const prefetchLeaderboardSnapshots = (serverSlug) => { + LEADERBOARD_METRICS.forEach((metric) => { + void getLeaderboardSnapshot(serverSlug, metric.key).catch(() => {}); + }); + }; + + const refreshServerContent = async () => { + const requestId = activeServerRequestId + 1; + activeServerRequestId = requestId; const activeMetricConfig = getLeaderboardMetricConfig(activeLeaderboardMetric); syncActiveButtons(selectorButtons, activeServerSlug); syncLeaderboardTabs(leaderboardTabButtons, activeLeaderboardMetric); - setRangeBadge(rangeNode, "Cargando rango temporal", false); - summaryNoteNode.textContent = - "Este bloque resume solo la cobertura ya registrada en la base local."; - renderSummaryLoading(summaryNode); weeklyTitleNode.textContent = activeMetricConfig.title; weeklyValueHeadingNode.textContent = activeMetricConfig.valueHeading; - weeklyWindowNoteNode.textContent = "Cargando ventana semanal..."; - setState(weeklyStateNode, "Cargando ranking semanal..."); - setState(recentStateNode, "Cargando partidas recientes..."); - weeklyTableNode.hidden = true; + setRangeBadge(rangeNode, "Cargando rango temporal", false); + summaryNoteNode.textContent = + "La vista esta leyendo snapshots precalculados del historico local."; + setSnapshotMeta(summarySnapshotMetaNode, "Cargando snapshot de resumen..."); + renderSummaryLoading(summaryNode); + weeklyWindowNoteNode.textContent = + "Cargando snapshot semanal del servidor activo..."; + setSnapshotMeta(weeklySnapshotMetaNode, "Preparando snapshot semanal..."); recentListNode.innerHTML = ""; + setState(recentStateNode, "Cargando partidas recientes..."); + setSnapshotMeta(recentSnapshotMetaNode, "Cargando snapshot de partidas..."); - const [summaryResult, leaderboardResult, recentMatchesResult] = + const cachedLeaderboardPayload = leaderboardCache.get( + buildLeaderboardSnapshotKey(activeServerSlug, activeLeaderboardMetric), + ); + if (cachedLeaderboardPayload) { + hydrateWeeklyLeaderboard( + { status: "fulfilled", value: cachedLeaderboardPayload }, + weeklyStateNode, + weeklyTableNode, + weeklyBodyNode, + weeklyTitleNode, + weeklyValueHeadingNode, + weeklyWindowNoteNode, + weeklySnapshotMetaNode, + activeMetricConfig, + ); + } else { + setState(weeklyStateNode, "Cargando ranking semanal..."); + weeklyTableNode.hidden = true; + } + + const targetServerSlug = activeServerSlug; + const targetMetric = activeLeaderboardMetric; + const [summaryResult, recentMatchesResult, leaderboardResult] = await Promise.allSettled([ - fetchJson( - `${backendBaseUrl}/api/historical/server-summary?server=${encodeURIComponent(activeServerSlug)}`, - ), - fetchJson( - `${backendBaseUrl}/api/historical/weekly-leaderboard?server=${encodeURIComponent(activeServerSlug)}&metric=${encodeURIComponent(activeLeaderboardMetric)}&limit=10`, - ), - fetchJson( - `${backendBaseUrl}/api/historical/recent-matches?server=${encodeURIComponent(activeServerSlug)}&limit=6`, - ), + getSummarySnapshot(targetServerSlug), + getRecentMatchesSnapshot(targetServerSlug), + getLeaderboardSnapshot(targetServerSlug, targetMetric), ]); - if (requestId !== refreshRequestId) { + if ( + requestId !== activeServerRequestId || + targetServerSlug !== activeServerSlug || + targetMetric !== activeLeaderboardMetric + ) { return; } - hydrateSummary(summaryResult, summaryNode, rangeNode, summaryNoteNode); + hydrateSummary( + summaryResult, + summaryNode, + rangeNode, + summaryNoteNode, + summarySnapshotMetaNode, + ); + hydrateRecentMatches( + recentMatchesResult, + recentStateNode, + recentListNode, + recentSnapshotMetaNode, + ); hydrateWeeklyLeaderboard( leaderboardResult, weeklyStateNode, @@ -102,9 +181,71 @@ document.addEventListener("DOMContentLoaded", () => { weeklyTitleNode, weeklyValueHeadingNode, weeklyWindowNoteNode, + weeklySnapshotMetaNode, activeMetricConfig, ); - hydrateRecentMatches(recentMatchesResult, recentStateNode, recentListNode); + + prefetchLeaderboardSnapshots(targetServerSlug); + }; + + const refreshLeaderboardContent = async () => { + const requestId = activeLeaderboardRequestId + 1; + activeLeaderboardRequestId = requestId; + const metricConfig = getLeaderboardMetricConfig(activeLeaderboardMetric); + const targetServerSlug = activeServerSlug; + const targetMetric = activeLeaderboardMetric; + + syncLeaderboardTabs(leaderboardTabButtons, activeLeaderboardMetric); + weeklyTitleNode.textContent = metricConfig.title; + weeklyValueHeadingNode.textContent = metricConfig.valueHeading; + + const cachedPayload = leaderboardCache.get( + buildLeaderboardSnapshotKey(targetServerSlug, targetMetric), + ); + if (cachedPayload) { + hydrateWeeklyLeaderboard( + { status: "fulfilled", value: cachedPayload }, + weeklyStateNode, + weeklyTableNode, + weeklyBodyNode, + weeklyTitleNode, + weeklyValueHeadingNode, + weeklyWindowNoteNode, + weeklySnapshotMetaNode, + metricConfig, + ); + return; + } + + weeklyWindowNoteNode.textContent = + "Cargando snapshot semanal del servidor activo..."; + setSnapshotMeta(weeklySnapshotMetaNode, "Cargando snapshot semanal..."); + setState(weeklyStateNode, "Cargando ranking semanal..."); + weeklyTableNode.hidden = true; + + const leaderboardResult = await settlePromise( + getLeaderboardSnapshot(targetServerSlug, targetMetric), + ); + + if ( + requestId !== activeLeaderboardRequestId || + targetServerSlug !== activeServerSlug || + targetMetric !== activeLeaderboardMetric + ) { + return; + } + + hydrateWeeklyLeaderboard( + leaderboardResult, + weeklyStateNode, + weeklyTableNode, + weeklyBodyNode, + weeklyTitleNode, + weeklyValueHeadingNode, + weeklyWindowNoteNode, + weeklySnapshotMetaNode, + metricConfig, + ); }; selectorButtons.forEach((button) => { @@ -118,7 +259,7 @@ document.addEventListener("DOMContentLoaded", () => { params.set("server", activeServerSlug); params.set("metric", activeLeaderboardMetric); window.history.replaceState({}, "", `?${params.toString()}`); - void refreshHistoricalView(); + void refreshServerContent(); }); }); @@ -133,46 +274,55 @@ document.addEventListener("DOMContentLoaded", () => { params.set("server", activeServerSlug); params.set("metric", activeLeaderboardMetric); window.history.replaceState({}, "", `?${params.toString()}`); - void refreshHistoricalView(); + void refreshLeaderboardContent(); }); }); - void refreshHistoricalView(); + prefetchLeaderboardSnapshots(activeServerSlug); + void refreshServerContent(); }); -function hydrateSummary(result, summaryNode, rangeNode, noteNode) { +function hydrateSummary(result, summaryNode, rangeNode, noteNode, snapshotMetaNode) { if (result.status !== "fulfilled") { renderSummaryError(summaryNode); - setRangeBadge(rangeNode, "Resumen historico no disponible", false); + setRangeBadge(rangeNode, "Snapshot de resumen no disponible", false); noteNode.textContent = - "No se pudo leer la cobertura historica registrada para este servidor."; + "No se pudo leer el resumen precalculado para este servidor."; + setSnapshotMeta(snapshotMetaNode, "Error al leer el snapshot de resumen."); return; } const payload = result.value?.data; - const items = payload?.items; - if (!Array.isArray(items) || items.length === 0) { + const summary = payload?.item; + if (!payload?.found || !summary) { renderSummaryEmpty(summaryNode); - setRangeBadge(rangeNode, "Sin cobertura historica", false); + setRangeBadge(rangeNode, "Sin snapshot de resumen", false); noteNode.textContent = - "Todavia no existe cobertura historica suficiente en la base local."; + "Todavia no existe un snapshot de resumen listo para este servidor."; + setSnapshotMeta(snapshotMetaNode, "Snapshot de resumen pendiente de generacion."); return; } - const summary = items[0]; const coverage = summary.coverage || {}; const timeRange = summary.time_range || {}; - const rangeLabel = buildCoverageBadgeLabel(coverage, timeRange); + const rangeLabel = buildCoverageBadgeLabel(coverage, { + start: payload?.source_range_start || timeRange.start, + end: payload?.source_range_end || timeRange.end, + }); setRangeBadge( rangeNode, rangeLabel || "Cobertura registrada parcial", - coverage.status === "week-plus", + coverage.status === "week-plus" && !payload?.is_stale, ); noteNode.textContent = buildSummaryNote( - payload?.summary_basis, - payload?.weekly_ranking_window_days, + "snapshot-precomputed", + 7, coverage, ); + setSnapshotMeta( + snapshotMetaNode, + buildSnapshotMetaText(payload, "Snapshot de resumen sin timestamp."), + ); summaryNode.innerHTML = [ renderSummaryCard("Servidor", summary.server?.name || "Servidor no disponible"), renderSummaryCard( @@ -181,7 +331,7 @@ function hydrateSummary(result, summaryNode, rangeNode, noteNode) { ), renderSummaryCard("Jugadores unicos", formatNumber(summary.unique_players)), renderSummaryCard( - "Datos registrados", + "Periodo registrado", formatCoverageDays(coverage.coverage_days), ), renderSummaryCard("Primera partida", formatTimestamp(coverage.first_match_at)), @@ -201,13 +351,15 @@ function hydrateWeeklyLeaderboard( titleNode, valueHeadingNode, noteNode, + snapshotMetaNode, metricConfig, ) { titleNode.textContent = metricConfig.title; valueHeadingNode.textContent = metricConfig.valueHeading; if (result.status !== "fulfilled") { noteNode.textContent = - "El ranking usa solo partidas cerradas dentro de la ultima ventana semanal."; + "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); tableNode.hidden = true; return; @@ -215,6 +367,16 @@ function hydrateWeeklyLeaderboard( const payload = result.value?.data; noteNode.textContent = buildWeeklyWindowNote(payload); + setSnapshotMeta( + snapshotMetaNode, + buildSnapshotMetaText(payload, "Snapshot semanal pendiente de generacion."), + ); + if (!payload?.found) { + setState(stateNode, metricConfig.emptyMessage); + tableNode.hidden = true; + return; + } + const items = payload?.items; if (!Array.isArray(items) || items.length === 0) { setState(stateNode, metricConfig.emptyMessage); @@ -238,13 +400,24 @@ function hydrateWeeklyLeaderboard( tableNode.hidden = false; } -function hydrateRecentMatches(result, stateNode, listNode) { +function hydrateRecentMatches(result, stateNode, listNode, snapshotMetaNode) { if (result.status !== "fulfilled") { setState(stateNode, "No se pudieron cargar las partidas recientes.", true); + setSnapshotMeta(snapshotMetaNode, "Error al leer el snapshot de partidas."); return; } - const items = result.value?.data?.items; + const payload = result.value?.data; + setSnapshotMeta( + snapshotMetaNode, + buildSnapshotMetaText(payload, "Snapshot de partidas pendiente de generacion."), + ); + if (!payload?.found) { + setState(stateNode, "Todavia no hay snapshot de partidas recientes disponible."); + return; + } + + const items = payload?.items; if (!Array.isArray(items) || items.length === 0) { setState(stateNode, "Todavia no hay partidas recientes disponibles."); return; @@ -320,6 +493,10 @@ function setRangeBadge(node, label, isFresh) { node.classList.toggle("status-chip--fallback", !isFresh); } +function setSnapshotMeta(node, message) { + node.textContent = message; +} + function syncActiveButtons(buttons, activeServerSlug) { buttons.forEach((button) => { button.classList.toggle( @@ -362,6 +539,18 @@ function getLeaderboardMetricConfig(metricKey) { ); } +function buildSummarySnapshotKey(serverSlug) { + return `summary:${serverSlug}`; +} + +function buildRecentMatchesSnapshotKey(serverSlug) { + return `recent:${serverSlug}`; +} + +function buildLeaderboardSnapshotKey(serverSlug, metricKey) { + return `leaderboard:${serverSlug}:${metricKey}`; +} + function buildRangeLabel(start, end) { if (!start && !end) { return ""; @@ -384,8 +573,8 @@ function buildCoverageBadgeLabel(coverage, timeRange) { function buildSummaryNote(summaryBasis, weeklyWindowDays, coverage) { const basisLabel = - summaryBasis === "persisted-import" - ? "la cobertura ya registrada en la base local" + summaryBasis === "snapshot-precomputed" + ? "el snapshot precalculado del historico local" : "el historico persistido disponible"; const weeklyWindowLabel = Number.isFinite(Number(weeklyWindowDays)) ? `${weeklyWindowDays} dias` @@ -394,15 +583,39 @@ function buildSummaryNote(summaryBasis, weeklyWindowDays, coverage) { if (status === "under-week") { return `Este bloque resume ${basisLabel}. Ahora mismo esa cobertura todavia no alcanza ${weeklyWindowLabel}.`; } - return "Datos generales registrados de los servidores"; + return `Resumen servido desde ${basisLabel}.`; } function buildWeeklyWindowNote(payload) { + if (!payload?.found) { + return "No existe un snapshot semanal listo para esta metrica en el servidor activo."; + } + const start = formatTimestamp(payload?.window_start); const end = formatTimestamp(payload?.window_end); const windowDays = Number(payload?.window_days); const daysLabel = Number.isFinite(windowDays) ? `${windowDays} dias` : "7 dias"; - return `Ranking calculado solo con partidas cerradas dentro de la ventana movil de ${daysLabel}: ${start} a ${end}.`; + return `Ranking servido desde snapshot semanal de ${daysLabel}: ${start} a ${end}.`; +} + +function buildSnapshotMetaText(payload, missingMessage) { + if (!payload?.generated_at) { + return missingMessage; + } + + const parts = [ + payload.is_stale + ? `Snapshot posiblemente desactualizado: ${formatTimestamp(payload.generated_at)}` + : `Snapshot generado: ${formatTimestamp(payload.generated_at)}`, + ]; + const sourceRangeLabel = buildRangeLabel( + payload?.source_range_start, + payload?.source_range_end, + ); + if (sourceRangeLabel) { + parts.push(`Fuente: ${sourceRangeLabel}`); + } + return parts.join(" | "); } function formatTopMaps(topMaps) { @@ -475,6 +688,37 @@ function formatTimestamp(timestamp) { }).format(value); } +async function getCachedJson(cache, pendingCache, key, url) { + if (cache.has(key)) { + return cache.get(key); + } + if (pendingCache.has(key)) { + return pendingCache.get(key); + } + + const request = fetchJson(url) + .then((payload) => { + cache.set(key, payload); + pendingCache.delete(key); + return payload; + }) + .catch((error) => { + pendingCache.delete(key); + throw error; + }); + pendingCache.set(key, request); + return request; +} + +async function settlePromise(promise) { + try { + const value = await promise; + return { status: "fulfilled", value }; + } catch (reason) { + return { status: "rejected", reason }; + } +} + async function fetchJson(url) { const response = await fetch(url); if (!response.ok) { diff --git a/frontend/historico.html b/frontend/historico.html index 267a8d9..9e295ee 100644 --- a/frontend/historico.html +++ b/frontend/historico.html @@ -75,6 +75,9 @@
Datos generales registrados de los servidores
+Cargando rango temporal @@ -98,6 +101,9 @@
Cargando ventana semanal...
+