diff --git a/ai/tasks/done/TASK-052-historical-ui-initial-load-performance-pass.md b/ai/tasks/done/TASK-052-historical-ui-initial-load-performance-pass.md new file mode 100644 index 0000000..58ae1fb --- /dev/null +++ b/ai/tasks/done/TASK-052-historical-ui-initial-load-performance-pass.md @@ -0,0 +1,72 @@ +# TASK-052-historical-ui-initial-load-performance-pass + +## Goal +Reducir drásticamente el tiempo percibido de carga inicial de `historico.html`, evitando peticiones innecesarias al entrar, eliminando precargas agresivas y permitiendo que resumen, ranking y partidas recientes se hidraten de forma progresiva e independiente. + +## Context +La página histórica ya consume snapshots rápidos, pero el comportamiento actual sigue generando una experiencia lenta: +- se lanzan varias peticiones de snapshots al entrar +- se precargan varias métricas de leaderboard demasiado pronto +- hay prefetch por interacción ligera o por estrategia demasiado agresiva +- la UI tarda en mostrar contenido útil porque espera más de la cuenta antes de hidratar bloques visibles + +El objetivo de esta task es que la carga inicial sea más ligera y que el usuario vea contenido útil antes, aunque el resto de bloques sigan llegando progresivamente. + +## Steps +1. Revisar `frontend/assets/js/historico.js` y la estrategia actual de carga inicial. +2. Identificar y reducir las peticiones lanzadas al entrar en la página para el servidor activo. +3. Hacer que en la carga inicial solo se pidan los datos estrictamente necesarios para pintar la primera vista útil, como mínimo: + - resumen del servidor activo + - leaderboard de la pestaña activa + - partidas recientes del servidor activo +4. Evitar que en la carga inicial se precarguen automáticamente todas las métricas del leaderboard si no son visibles todavía. +5. Eliminar o reducir estrategias agresivas de prefetch por `hover`, `focus` o cambios ligeros de interacción si están disparando peticiones innecesarias. +6. Cambiar la hidratación para que los bloques visibles se pinten de forma progresiva e independiente, sin esperar a que todo el conjunto termine si un bloque ya está listo. +7. Mantener estados de loading por bloque, pero evitar que toda la página parezca “bloqueada” por el snapshot más lento. +8. Revisar y ajustar el caché frontend para que ayude a la velocidad sin mantener estados vacíos obsoletos demasiado tiempo. +9. No cambiar la semántica funcional de los datos históricos, solo mejorar la estrategia de carga y renderizado. +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/assets/js/historico.js +- frontend/historico.html +- frontend/assets/css/historico.css +- opcionalmente documentación mínima si el comportamiento visible cambia de forma relevante + +## Constraints +- No crear páginas nuevas. +- No romper la UI histórica existente. +- No introducir frameworks nuevos. +- No hacer cambios destructivos. +- Mantener el trabajo centrado en rendimiento percibido y estrategia de carga. + +## Validation +- La carga inicial hace menos peticiones que antes. +- La página empieza a mostrar contenido útil antes. +- Resumen, leaderboard activo y partidas recientes se hidratan de forma progresiva. +- No se precargan de forma agresiva métricas no visibles en el primer paint. +- Los cambios quedan committeados y se hace push si el entorno lo permite. + +## Change Budget +- Preferir menos de 4 archivos modificados. +- Preferir menos de 220 líneas cambiadas. + +## Outcome +- La carga inicial de `historico.html` ya no dispara precarga agresiva de todos los snapshots ni peticiones por `hover` o `focus` sobre el selector de servidor. +- La UI reutiliza snapshots cacheados de resumen, ranking activo y partidas recientes cuando existen, reduciendo la espera percibida en entradas posteriores y cambios de servidor. +- Resumen, leaderboard activo y partidas recientes se hidratan ahora de forma independiente; el bloque que llega antes se pinta antes, sin esperar al snapshot más lento. +- Validación local: +- `node --check frontend/assets/js/historico.js` +- revisión de `git diff --name-only` para confirmar alcance limitado a `frontend/assets/js/historico.js` y el archivo de task diff --git a/frontend/assets/js/historico.js b/frontend/assets/js/historico.js index 62bf042..1c3ad4b 100644 --- a/frontend/assets/js/historico.js +++ b/frontend/assets/js/historico.js @@ -116,21 +116,11 @@ document.addEventListener("DOMContentLoaded", () => { `${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 prefetchServerSnapshots = (serverSlug) => { - void getSummarySnapshot(serverSlug).catch(() => {}); - void getRecentMatchesSnapshot(serverSlug).catch(() => {}); - prefetchLeaderboardSnapshots(serverSlug); - }; - const refreshServerContent = async () => { const requestId = activeServerRequestId + 1; + const leaderboardRequestId = activeLeaderboardRequestId + 1; activeServerRequestId = requestId; + activeLeaderboardRequestId = leaderboardRequestId; const activeMetricConfig = getLeaderboardMetricConfig(activeLeaderboardMetric); const activeServerLabel = getHistoricalServerLabel(activeServerSlug); @@ -153,6 +143,20 @@ document.addEventListener("DOMContentLoaded", () => { setState(recentStateNode, "Cargando partidas recientes..."); setSnapshotMeta(recentSnapshotMetaNode, "Cargando snapshot de partidas..."); + const cachedSummaryPayload = readCachedPayload( + summaryCache, + buildSummarySnapshotKey(activeServerSlug), + ); + if (cachedSummaryPayload) { + hydrateSummary( + { status: "fulfilled", value: cachedSummaryPayload }, + summaryNode, + rangeNode, + summaryNoteNode, + summarySnapshotMetaNode, + ); + } + const cachedLeaderboardPayload = readCachedPayload( leaderboardCache, buildLeaderboardSnapshotKey(activeServerSlug, activeLeaderboardMetric), @@ -174,49 +178,74 @@ document.addEventListener("DOMContentLoaded", () => { weeklyTableNode.hidden = true; } - const targetServerSlug = activeServerSlug; - const targetMetric = activeLeaderboardMetric; - const [summaryResult, recentMatchesResult, leaderboardResult] = - await Promise.allSettled([ - getSummarySnapshot(targetServerSlug), - getRecentMatchesSnapshot(targetServerSlug), - getLeaderboardSnapshot(targetServerSlug, targetMetric), - ]); - - if ( - requestId !== activeServerRequestId || - targetServerSlug !== activeServerSlug || - targetMetric !== activeLeaderboardMetric - ) { - return; + const cachedRecentMatchesPayload = readCachedPayload( + recentMatchesCache, + buildRecentMatchesSnapshotKey(activeServerSlug), + ); + if (cachedRecentMatchesPayload) { + hydrateRecentMatches( + { status: "fulfilled", value: cachedRecentMatchesPayload }, + recentStateNode, + recentListNode, + recentSnapshotMetaNode, + ); } - hydrateSummary( - summaryResult, - summaryNode, - rangeNode, - summaryNoteNode, - summarySnapshotMetaNode, - ); - hydrateRecentMatches( - recentMatchesResult, - recentStateNode, - recentListNode, - recentSnapshotMetaNode, - ); - hydrateWeeklyLeaderboard( - leaderboardResult, - weeklyStateNode, - weeklyTableNode, - weeklyBodyNode, - weeklyTitleNode, - weeklyValueHeadingNode, - weeklyWindowNoteNode, - weeklySnapshotMetaNode, - activeMetricConfig, - ); + const targetServerSlug = activeServerSlug; + const targetMetric = activeLeaderboardMetric; + void settlePromise(getSummarySnapshot(targetServerSlug)).then((summaryResult) => { + if (!isActiveServerRequest(requestId, targetServerSlug, targetMetric)) { + return; + } - prefetchServerSnapshots(targetServerSlug); + hydrateSummary( + summaryResult, + summaryNode, + rangeNode, + summaryNoteNode, + summarySnapshotMetaNode, + ); + }); + + void settlePromise(getRecentMatchesSnapshot(targetServerSlug)).then((recentMatchesResult) => { + if (!isActiveServerRequest(requestId, targetServerSlug, targetMetric)) { + return; + } + + hydrateRecentMatches( + recentMatchesResult, + recentStateNode, + recentListNode, + recentSnapshotMetaNode, + ); + }); + + void settlePromise( + getLeaderboardSnapshot(targetServerSlug, targetMetric), + ).then((leaderboardResult) => { + if ( + !isActiveLeaderboardRequest( + requestId, + leaderboardRequestId, + targetServerSlug, + targetMetric, + ) + ) { + return; + } + + hydrateWeeklyLeaderboard( + leaderboardResult, + weeklyStateNode, + weeklyTableNode, + weeklyBodyNode, + weeklyTitleNode, + weeklyValueHeadingNode, + weeklyWindowNoteNode, + weeklySnapshotMetaNode, + activeMetricConfig, + ); + }); }; const refreshLeaderboardContent = async () => { @@ -284,14 +313,6 @@ document.addEventListener("DOMContentLoaded", () => { }; selectorButtons.forEach((button) => { - button.addEventListener("mouseenter", () => { - const nextServerSlug = normalizeServerSlug(button.dataset.serverSlug); - prefetchServerSnapshots(nextServerSlug); - }); - button.addEventListener("focus", () => { - const nextServerSlug = normalizeServerSlug(button.dataset.serverSlug); - prefetchServerSnapshots(nextServerSlug); - }); button.addEventListener("click", () => { const nextServerSlug = normalizeServerSlug(button.dataset.serverSlug); if (nextServerSlug === activeServerSlug) { @@ -321,10 +342,29 @@ document.addEventListener("DOMContentLoaded", () => { }); }); - prefetchServerSnapshots(activeServerSlug); void refreshServerContent(); }); +function isActiveServerRequest(requestId, serverSlug, metricKey) { + return ( + requestId === activeServerRequestId && + serverSlug === activeServerSlug && + metricKey === activeLeaderboardMetric + ); +} + +function isActiveLeaderboardRequest( + serverRequestId, + leaderboardRequestId, + serverSlug, + metricKey, +) { + return ( + isActiveServerRequest(serverRequestId, serverSlug, metricKey) && + leaderboardRequestId === activeLeaderboardRequestId + ); +} + function hydrateSummary(result, summaryNode, rangeNode, noteNode, snapshotMetaNode) { if (result.status !== "fulfilled") { renderSummaryError(summaryNode);