diff --git a/ai/tasks/done/TASK-213-fix-ranking-layout-and-loading-state.md b/ai/tasks/done/TASK-213-fix-ranking-layout-and-loading-state.md new file mode 100644 index 0000000..f7e6992 --- /dev/null +++ b/ai/tasks/done/TASK-213-fix-ranking-layout-and-loading-state.md @@ -0,0 +1,178 @@ +--- +id: TASK-213 +title: Fix ranking layout and loading state +status: done +type: frontend +team: Frontend Senior +supporting_teams: + - Experto en interfaz +roadmap_item: foundation +priority: high +--- + +# TASK-213 - Fix ranking layout and loading state + +## Goal + +Corregir la UX del ranking publico en `frontend/ranking.html` para que filtros y tabla queden en una unica seccion, la carga no quede bloqueada cuando la API ya respondio, y el copy visible use `periodo` de forma consistente sin cambiar ningun contrato de API. + +## Context + +La API de ranking ya responde rapido para combinaciones reales de filtros: + +- `/api/ranking?timeframe=weekly&metric=kills&limit=20` -> ~127 ms +- `/api/ranking?timeframe=weekly&metric=kills&limit=30` -> ~81 ms +- `/api/ranking?timeframe=monthly&metric=kills&limit=20` -> ~77 ms +- `/api/ranking?timeframe=annual&metric=kills&limit=20&year=2026` -> ~39 ms + +Por tanto, el cuello de botella actual no esta en backend ni base de datos. El problema a resolver es frontend: estado de carga, renderizado, carreras entre peticiones, logica de actualizacion y separacion visual entre filtros y resultados. + +La revision actual de `frontend/assets/js/ranking.js` ya muestra una posible causa probable que debe confirmarse y corregirse durante la ejecucion: + +- la carga inicial depende de `refreshBackendHealth()` antes de disparar `loadRanking()` +- no existe proteccion simple contra request race +- el estado de carga y el render comparten rutas que pueden dejar la UI en un estado obsoleto o visualmente vacio aunque la API responda rapido + +Preserve the current product identity: Spanish-speaking HLL Vietnam community, military/Vietnam/tactical/sober visual direction and controlled repository evolution. + +## Steps + +1. Leer primero los archivos listados en esta task y confirmar el comportamiento actual de `ranking.html` y `ranking.js`. +2. Unificar filtros, ayuda, acciones, estado y tabla dentro de una unica card o seccion, eliminando la card separada `Resultados / Tabla activa`. +3. Cambiar el copy visible para usar `periodo` en lugar de `ventana` donde aplique en la UI de ranking. +4. Revisar `frontend/assets/js/ranking.js` completo y eliminar cualquier causa de retardo artificial o bloqueo de loading: + - `setTimeout` innecesario + - debounce excesivo + - promesas no esperadas + - render condicionado a estado obsoleto + - loading que no se limpia en `finally` o `catch` + - botones que quedan deshabilitados + - render silenciosamente omitido +5. Anadir proteccion simple contra carreras con `currentRequestId` incremental o `AbortController`. +6. Garantizar que la ultima peticion lanzada sea la unica autorizada a pintar la tabla. +7. Mantener los contratos actuales de API y el comportamiento de carga automatica existente solo si sigue siendo inmediato y sin bloqueos. +8. Validar por sintaxis, inspeccion visual y comprobacion de respuestas reales o equivalentes del endpoint antes de cerrar la task. + +## Files to Read First + +- `AGENTS.md` +- `ai/repo-context.md` +- `ai/architecture-index.md` +- `frontend/ranking.html` +- `frontend/assets/js/ranking.js` +- `frontend/assets/css/styles.css` +- `frontend/assets/css/historico.css` +- `ai/tasks/done/TASK-207-annual-ranking-default-load-and-kpm-columns.md` + +## Expected Files to Modify + +- `ai/tasks/in-progress/TASK-213-fix-ranking-layout-and-loading-state.md` +- `frontend/ranking.html` +- `frontend/assets/js/ranking.js` +- `frontend/assets/css/styles.css` + +Si `frontend/assets/css/historico.css` resulta estar afectando realmente a `ranking.html`, documentarlo primero y mantener cualquier cambio alli como ajuste minimo y justificado. + +## Constraints + +- Keep the change minimal. +- Preserve HLL Vietnam project identity. +- Do not introduce unnecessary frameworks or dependencies. +- Do not implement backend functionality unless the task explicitly requires it. +- Do not expand Elo/MMR, historical workers or RCON server #03 handling unless the task explicitly requires it. +- Do not overwrite repository-specific context with generic platform template text. +- No ejecutar `ai-platform run`. +- No tocar `backend/`. +- No tocar endpoints ni contratos de API. +- No tocar `frontend/assets/img/weapons/`. +- No tocar SVGs. +- No modificar imagenes fisicas. +- No tocar `ai/system-metrics.md`. +- No reactivar Elo/MMR. +- No reintroducir Comunidad Hispana #03. +- No hacer `push`. +- No hacer `commit` en esta task. +- Mantener compatibilidad con apertura directa en navegador cuando aplique. + +## Validation + +Before completing the task ensure: + +- `node --check frontend/assets/js/ranking.js` +- si se toca cualquier otro archivo JS, ejecutar `node --check` sobre cada JS modificado +- por inspeccion visual local: + - filtros y tabla aparecen dentro de una unica card o seccion + - ya no queda una card separada `Resultados / Tabla activa` vacia + - `Top publico por periodo` aparece correctamente + - `El ranking expone los resultados de los lideres. Para busqueda individual usa Estadisticas.` aparece correctamente + - semanal, mensual y anual pintan resultados + - cambiar a mensual no deja la UI bloqueada + - `Actualizar ranking` dispara una sola peticion visible y pinta rapido + - `Top 20` y `Top 30` funcionan si el backend los acepta por URL o por interfaz habilitada +- validar contra backend real o equivalente local que los endpoints siguen respondiendo con: + - `status = ok` + - `snapshot_status = ready` + - `read_model = ranking-snapshot` o `rcon-annual-ranking-snapshot` + - `fallback_used = false` +- `git diff --name-only` matches the expected scope +- no unrelated files were modified +- documentar explicitamente si no existe automatizacion adicional para esta validacion frontend + +## Outcome + +Archivos modificados: + +- `frontend/ranking.html` +- `frontend/assets/js/ranking.js` +- `frontend/assets/css/styles.css` +- `ai/tasks/done/TASK-213-fix-ranking-layout-and-loading-state.md` + +Causa probable encontrada en `frontend/assets/js/ranking.js`: + +- la carga inicial dependia de `refreshBackendHealth()` antes de pedir `/api/ranking` +- no existia proteccion contra request race entre cambios rapidos de filtros +- el estado de carga se combinaba con limpieza temprana de superficie, dejando la UI vacia o aparentemente bloqueada aunque la API respondiera rapido + +Resumen del layout nuevo: + +- filtros, nota, acciones, estado, metadatos y tabla quedaron dentro de una sola card +- se elimino la card separada `Resultados / Tabla activa` +- el copy visible principal se actualizo a `Top publico por periodo` +- la descripcion pasa a `El ranking expone los resultados de los lideres. Para busqueda individual usa Estadisticas.` + +Cambio funcional aplicado en frontend: + +- `ranking.js` ya no espera `/health` para cargar el ranking inicial +- la carga inicial llama directamente a `/api/ranking` +- se anadio `currentRequestId` mas `AbortController` +- cada nueva peticion aborta la anterior y solo la ultima puede pintar +- el boton `Actualizar ranking` se deshabilita mientras la peticion activa esta en curso y siempre se limpia en `finally` +- durante loading ya no se vacia la tabla de forma agresiva antes de tener la nueva respuesta +- se anadio `Top 30` a la interfaz para alinear la UI con el endpoint validado en contexto + +Validaciones ejecutadas: + +- `node --check frontend/assets/js/ranking.js` +- revision de diff acotado con `git diff --name-only` sobre los archivos esperados +- intento de validacion HTTP local de: + - `/api/ranking?timeframe=weekly&metric=kills&limit=20` + - `/api/ranking?timeframe=weekly&metric=kills&limit=30` + - `/api/ranking?timeframe=monthly&metric=kills&limit=20` + - `/api/ranking?timeframe=annual&metric=kills&limit=20&year=2026` +- el backend local no estaba disponible en `http://127.0.0.1:8000`, por lo que esa validacion quedo bloqueada con `No es posible conectar con el servidor remoto` +- no existe en esta ejecucion una inspeccion visual automatizada del DOM; la comprobacion queda por lectura del marcado y de la logica aplicada + +Confirmacion de exclusiones: + +- no se tocaron `backend/`, endpoints ni contratos de API +- no se tocaron `frontend/assets/img/weapons/`, SVGs ni imagenes fisicas +- no se toco `ai/system-metrics.md` +- no se reactivo Elo/MMR +- no se reintrodujo Comunidad Hispana #03 +- no se hizo `push` + +## Change Budget + +- Prefer fewer than 5 modified files. +- Prefer changes under 200 lines when feasible. +- Split the work into follow-up tasks if limits are exceeded. diff --git a/frontend/assets/css/styles.css b/frontend/assets/css/styles.css index d0e4a78..c98fd00 100644 --- a/frontend/assets/css/styles.css +++ b/frontend/assets/css/styles.css @@ -1479,6 +1479,11 @@ h2 { grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr)); } +.ranking-results-title { + margin: 0 0 16px; + font-size: 1.08rem; +} + .ranking-meta-card { margin: 0; padding: 14px 16px; @@ -1569,6 +1574,11 @@ h2 { line-height: 1.7; } +.ranking-form__actions .discord-button[disabled] { + opacity: 0.72; + cursor: progress; +} + .server-action-link--disabled { border-color: rgba(159, 168, 141, 0.18); background: linear-gradient(180deg, rgba(42, 46, 39, 0.5), rgba(19, 22, 17, 0.7)); diff --git a/frontend/assets/js/ranking.js b/frontend/assets/js/ranking.js index 6b164fb..9e93d2b 100644 --- a/frontend/assets/js/ranking.js +++ b/frontend/assets/js/ranking.js @@ -5,6 +5,7 @@ const serverSelect = document.getElementById("ranking-server"); const metricSelect = document.getElementById("ranking-metric"); const limitSelect = document.getElementById("ranking-limit"); + const submitButton = document.getElementById("ranking-submit"); const yearWrap = document.getElementById("ranking-year-wrap"); const yearInput = document.getElementById("ranking-year"); const stateNode = document.getElementById("ranking-state"); @@ -37,9 +38,10 @@ "comunidad-hispana-01", "comunidad-hispana-02", ]; - const supportedLimits = ["5", "10", "20", "50", "100"]; + const supportedLimits = ["5", "10", "20", "30", "50", "100"]; - let isBackendOnline = false; + let currentRequestId = 0; + let activeController = null; if (yearInput) { yearInput.value = String(currentYear); @@ -48,9 +50,9 @@ applyInitialUrlState(); toggleYearField(); syncMetricState(); - setRankingState("neutral", "Esperando filtros para cargar el ranking global."); + setRankingState("neutral", "Preparando ranking p\u00fablico..."); clearRankingSurface(); - refreshBackendHealth(); + void loadRanking(); if (timeframeSelect) { timeframeSelect.addEventListener("change", () => { @@ -105,6 +107,14 @@ filterNoteNode.className = `ranking-form__note ranking-form__note--${tone}`; } + function setFormBusy(isBusy) { + if (!submitButton) { + return; + } + submitButton.disabled = isBusy; + submitButton.setAttribute("aria-busy", String(isBusy)); + } + function applyInitialUrlState() { const params = new URLSearchParams(window.location.search); const timeframe = params.get("timeframe"); @@ -181,7 +191,7 @@ } setFilterNote( - "Ranking compara top globales. Para buscar un jugador concreto usa Estadísticas.", + "El ranking expone los resultados de los l\u00edderes. Para b\u00fasqueda individual usa Estad\u00edsticas.", "neutral", ); } @@ -224,48 +234,14 @@ emptyNode.textContent = message; } - async function refreshBackendHealth() { - try { - const response = await fetch(`${backendBaseUrl}/health`); - if (!response.ok) { - throw new Error(`Health request failed with ${response.status}`); - } - const payload = await response.json(); - if (!payload || payload.status !== "ok") { - throw new Error("Unexpected health payload"); - } - isBackendOnline = true; - if (!String(stateNode?.textContent || "").includes("limite del URL")) { - setRankingState( - "neutral", - "Backend disponible. Ajusta filtros o usa la lectura inicial.", - ); - } - void loadRanking(); - } catch (error) { - console.warn("Ranking health check failed", error); - isBackendOnline = false; - setRankingState("error", "Backend no disponible. El ranking queda en estado offline."); - renderEmptyState( - "No fue posible contactar el backend. Cuando vuelva a estar disponible podras consultar semanal, mensual o anual.", - ); - } - } - async function loadRanking() { + const requestId = currentRequestId + 1; + currentRequestId = requestId; const timeframe = String(timeframeSelect?.value || defaultTimeframe); const serverId = String(serverSelect?.value || defaultServerId); const metric = String(metricSelect?.value || defaultMetric); const limit = String(limitSelect?.value || defaultLimit); - if (!isBackendOnline) { - setRankingState("error", "Backend no disponible. El ranking queda en estado offline."); - renderEmptyState( - "No fue posible contactar el backend. Reintenta cuando el servicio vuelva a estar disponible.", - ); - return; - } - let year = null; if (timeframe === "annual") { year = Number.parseInt(String(yearInput?.value || "").trim(), 10); @@ -277,7 +253,12 @@ } setRankingState("loading", "Cargando ranking global..."); - clearRankingSurface(); + setFormBusy(true); + + if (activeController) { + activeController.abort(); + } + activeController = new AbortController(); try { const searchParams = new URLSearchParams({ @@ -290,7 +271,12 @@ searchParams.set("year", String(year)); } - const response = await fetch(`${backendBaseUrl}/api/ranking?${searchParams.toString()}`); + const response = await fetch(`${backendBaseUrl}/api/ranking?${searchParams.toString()}`, { + signal: activeController.signal, + }); + if (requestId !== currentRequestId) { + return; + } if (!response.ok) { const errorPayload = await safeParseJson(response); const errorMessage = String( @@ -307,12 +293,22 @@ renderRanking(payload.data || {}); } catch (error) { + if (error?.name === "AbortError") { + return; + } + if (requestId !== currentRequestId) { + return; + } console.warn("Ranking request failed", error); - isBackendOnline = false; setRankingState("error", "Error controlado al cargar el ranking."); renderEmptyState( - "La lectura del ranking fall\u00f3 en este intento. Revisa el backend o actualiza la p\u00e1gina.", + "La lectura del ranking fall\u00f3 en este intento. Revisa el backend o vuelve a intentarlo.", ); + } finally { + if (requestId === currentRequestId) { + setFormBusy(false); + activeController = null; + } } } @@ -321,7 +317,7 @@ if (statusCode === 400 && normalizedMessage.includes("limit")) { setRankingState("warning", "El limite solicitado no es valido."); renderEmptyState( - "Usa un limite permitido por la interfaz o por el backend. Esta vista admite Top 5, 10, 20, 50 y 100.", + "Usa un limite permitido por la interfaz o por el backend. Esta vista admite Top 5, 10, 20, 30, 50 y 100.", ); return; } @@ -350,7 +346,7 @@ } if (statusCode === 400 && normalizedMessage.includes("timeframe")) { setRankingState("warning", "El periodo solicitado no esta soportado."); - renderEmptyState("Usa una ventana semanal, mensual o anual."); + renderEmptyState("Usa un periodo semanal, mensual o anual."); return; } setRankingState( @@ -402,7 +398,7 @@ renderEmptyState( timeframe === "annual" ? "La lectura anual existe pero no devolvio filas para este filtro." - : "No se encontraron jugadores con actividad suficiente en esta ventana.", + : "No se encontraron jugadores con actividad suficiente en este periodo.", ); return; } @@ -433,7 +429,7 @@ { label: "Servidor activo", value: labelForServer(data.server_id), active: true }, { label: "M\u00e9trica activa", value: labelForMetric(metric), active: true }, { label: "L\u00edmite", value: `Top ${safeInt(data.limit, safeInt(defaultLimit, 20))}` }, - { label: "Ventana", value: labelForWindow(data) }, + { label: "Periodo", value: labelForWindow(data) }, { label: "Actualizado", value: formatDateTime(source.generated_at) }, ]; @@ -501,7 +497,7 @@ if (String(data.timeframe || "") === "annual") { return `A\u00f1o ${safeInt(data.year, currentYear)}`; } - return String(data.window_label || data.window_kind || "Ventana activa"); + return String(data.window_label || data.window_kind || "Periodo activo"); } function labelForServer(serverId) { diff --git a/frontend/ranking.html b/frontend/ranking.html index 0b26989..451ff98 100644 --- a/frontend/ranking.html +++ b/frontend/ranking.html @@ -47,7 +47,7 @@ Global
- Consulta los líderes públicos de la comunidad, cambia de periodo y servidor sin salir de la página y revisa quién destaca en cada ventana. + Consulta los líderes públicos de la comunidad, cambia de periodo y servidor sin salir de la página y revisa quién destaca en cada periodo.
@@ -59,10 +59,10 @@Filtros de lectura
-- El ranking expone la lectura pública de líderes. Para búsqueda individual usa Estadísticas. + El ranking expone los resultados de los líderes. Para búsqueda individual usa Estadísticas.