diff --git a/ai/tasks/done/TASK-216-clean-ranking-ui-and-fix-kpm-metric.md b/ai/tasks/done/TASK-216-clean-ranking-ui-and-fix-kpm-metric.md new file mode 100644 index 0000000..f902eff --- /dev/null +++ b/ai/tasks/done/TASK-216-clean-ranking-ui-and-fix-kpm-metric.md @@ -0,0 +1,236 @@ +--- +id: TASK-216 +title: Clean ranking UI and fix KPM metric +status: done +type: frontend +team: Frontend Senior +supporting_teams: + - Backend Senior + - Arquitecto Python + - Experto en interfaz +roadmap_item: foundation +priority: high +--- + +# TASK-216 - Clean ranking UI and fix KPM metric + +## Goal + +Limpiar la UX de `frontend/ranking.html`, eliminar estados y controles redundantes, corregir el uso conceptual de KPM y ampliar el ranking anual solo para metricas soportadas por snapshots/read models seguros sin introducir lecturas runtime pesadas en requests publicos. + +## Context + +Despues de `TASK-213`, la vista de ranking ya carga rapido y concentra filtros + tabla dentro de una sola card. Sin embargo, la revision visual detecto varios residuos de UX y un problema funcional serio: la interfaz seguia mostrando mensajes redundantes, mantenia un boton `Actualizar ranking` innecesario y exponia `KPM` usando un valor que correspondia a `kills_per_match`. + +En paralelo, `TASK-211` dejo la lectura publica annual optimizada para snapshots anuales, pero `backend/app/rcon_annual_rankings.py` sigue restringiendo annual a `kills`. Esta task revisa ese limite sin introducir lecturas publicas runtime sobre `rcon_match_player_stats`. + +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 y confirmar el estado actual de UI, payloads y snapshots de ranking. +2. Auditar si existe tiempo jugado real por jugador antes de tocar la UI. +3. Limpiar la UI de `ranking.html` y `ranking.js`: + - eliminar del DOM visible el texto `El ranking expone los resultados de los lideres. Para busqueda individual usa Estadisticas.` + - eliminar el estado informativo tipo `Kills listo en semanal para Todos los servidores.` + - mantener solo estados utiles de loading, error y metadatos compactos + - quitar el boton `Actualizar ranking` + - cargar automaticamente al entrar y al cambiar periodo, servidor, metrica o limite + - mover `Buscar jugador en Estadisticas` por encima de los filtros dentro de la misma card +4. Ajustar el modo annual en frontend: + - no mostrar input de ano + - usar `2026` internamente en JS + - no mostrar mensaje de limitacion anual a kills +5. No exponer metricas annual que no tengan snapshots propios y seguros. +6. Documentar la decision final sobre KPM y el diseno futuro de `player_active_seconds`. + +## Files to Read First + +- `AGENTS.md` +- `ai/repo-context.md` +- `ai/architecture-index.md` +- `frontend/ranking.html` +- `frontend/assets/js/ranking.js` +- `backend/app/rcon_annual_rankings.py` + +Tambien se revisaron: + +- `frontend/assets/css/styles.css` +- `backend/app/payloads.py` +- `backend/app/postgres_rcon_storage.py` +- `backend/app/rcon_historical_leaderboards.py` +- `backend/app/rcon_historical_player_stats.py` +- `backend/app/rcon_admin_log_storage.py` +- `backend/app/rcon_admin_log_materialization.py` +- `backend/app/historical_runner.py` +- `ai/tasks/done/TASK-211-optimize-annual-ranking-read-path.md` +- `ai/tasks/done/TASK-213-fix-ranking-layout-and-loading-state.md` +- `ai/tasks/done/TASK-215-optimize-weekly-monthly-ranking-read-path.md` +- `docs/PERFORMANCE_PUBLIC_QUERY_AUDIT.md` + +## Expected Files to Modify + +- `frontend/ranking.html` +- `frontend/assets/js/ranking.js` +- `frontend/assets/css/styles.css` +- `ai/tasks/done/TASK-216-clean-ranking-ui-and-fix-kpm-metric.md` + +## Constraints + +- Keep the change minimal. +- Preserve HLL Vietnam project identity. +- Do not introduce unnecessary frameworks or dependencies. +- Do not overwrite repository-specific context with generic platform template text. +- No ejecutar `ai-platform run`. +- 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. +- No introducir runtime pesado ni fallback publico sobre `rcon_match_player_stats`. +- La lectura publica annual debe seguir leyendo unicamente: + - `rcon_annual_ranking_snapshots` + - `rcon_annual_ranking_snapshot_items` +- Weekly/monthly/annual deben seguir priorizando read models/snapshots con `fallback_used = false` en la ruta publica normal. +- No aceptar que `kills_per_match` se muestre como `KPM`. +- Si no existe tiempo jugado fiable materializado, no inventar `KPM`. +- Mantener compatibilidad con apertura directa en navegador cuando aplique. + +## Validation + +Before completing the task ensure: + +- `node --check frontend/assets/js/ranking.js` +- validar endpoints: + - `/api/ranking?timeframe=weekly&metric=kills&limit=20` + - `/api/ranking?timeframe=monthly&metric=kills&limit=20` + - `/api/ranking?timeframe=annual&metric=kills&limit=20&year=2026` +- por inspeccion visual o revision DOM: + - no aparece boton `Actualizar ranking` + - `Buscar jugador en Estadisticas` queda encima de filtros y dentro de la misma card + - no aparecen los textos redundantes eliminados + - annual no muestra input de ano + - annual no muestra mensaje de limitacion a kills + - la columna `KPM` no aparece si no existe KPM real + - si no hay minutos reales, aparece `Kills/partida` +- `git diff --name-only` matches the expected scope +- no unrelated files were modified + +## Outcome + +Archivos modificados por esta task: + +- `frontend/ranking.html` +- `frontend/assets/js/ranking.js` +- `frontend/assets/css/styles.css` +- `ai/tasks/done/TASK-216-clean-ranking-ui-and-fix-kpm-metric.md` + +Resultado de auditoria KPM: + +- No existe hoy un campo materializado y fiable de tiempo jugado real por jugador listo para el ranking publico. +- Se encontro `rcon_player_profile_snapshots.play_time`, pero es texto procedente de snapshots de perfil, no un campo numerico normalizado por jugador/partida ni agregado en los read models publicos de ranking. +- Se encontraron `rcon_match_player_stats.first_seen_server_time` y `rcon_match_player_stats.last_seen_server_time`, pero representan presencia observada por eventos dentro de una partida, no minutos reales jugados materializados como contrato estable de ranking. +- Se encontraron `rcon_materialized_matches.started_at`, `ended_at`, `started_server_time` y `ended_server_time`, pero son duracion de partida o ventana de partida, no tiempo real por jugador. +- Las tablas `ranking_snapshot_items`, `rcon_annual_ranking_snapshot_items` y `player_period_stats` no tienen `player_active_seconds`, `minutes_played`, `seconds_played` ni equivalente fiable. + +Decision final sobre KPM: + +- No se implemento KPM real. +- Se elimino el uso visible de `KPM` en `ranking.html` y `ranking.js`. +- `kills_per_match` se conserva solo bajo el nombre `Kills/partida`. +- No se reutilizo `kills_per_match` como kills por minuto. + +Propuesta para KPM real futuro: + +- Crear `player_active_seconds` como campo numerico base por jugador y partida. +- Ubicacion preferida: `rcon_match_player_stats`, si se acepta extender la tabla materializada principal. +- Alternativa: nueva tabla derivada `rcon_match_player_presence` con `target_key`, `match_key`, `player_id`, `first_seen_server_time`, `last_seen_server_time`, `active_seconds`, `quality`. +- Relleno exacto: eventos `connected` y `disconnected` dentro de limites `match_start`/`match_end`. +- Relleno razonable: presencia observada por eventos `kill`, `team_switch`, `chat`, `message`, acotada por `first_seen_server_time` y `last_seen_server_time`. +- Relleno estimado: duracion completa de partida solo como calidad `estimated`, no apta para publicar como KPM real sin etiqueta. +- Criterios de calidad propuestos: + - `exact`: join/leave real dentro de partida. + - `observed`: presencia inferida por eventos del jugador. + - `estimated`: duracion completa de partida. + - `unknown`: no calcular. +- Read models a extender cuando exista el campo: + - `player_period_stats` + - `ranking_snapshot_items` + - `rcon_annual_ranking_snapshot_items` +- Task futura recomendada: `TASK-XXX-materialize-player-active-seconds-for-real-kpm`. + +Decision sobre metricas annual: + +- No se ampliaron metricas annual en esta task. +- Annual sigue exponiendo solo `kills` porque es la unica metrica con snapshot anual seguro ya soportado por `rcon_annual_rankings.py`. +- No se usa snapshot top kills para representar `deaths`, `teamkills`, `matches_considered`, `kd_ratio` ni `kills_per_match`. +- No se introdujo runtime publico pesado ni fallback sobre `rcon_match_player_stats`. + +Cambios de UI aplicados: + +- Se elimino el texto redundante de introduccion dentro de la card. +- Se elimino el boton `Actualizar ranking`. +- La tabla sigue cargando automaticamente al entrar y al cambiar periodo, servidor, metrica o limite. +- `Buscar jugador en Estadisticas` queda encima de los filtros dentro de la misma card. +- En annual no hay input visible de ano. +- `ranking.js` usa `2026` internamente para annual. +- Se elimino el mensaje visible de annual limitado a kills. +- Se elimino el estado de exito tipo `Kills listo en semanal para Todos los servidores.` +- La metrica `kills_per_match` se muestra como `Kills/partida`. + +Comandos de produccion: + +- No hace falta regenerar snapshots para este cambio de UI. +- Si falta el snapshot anual existente de kills para 2026, regenerarlo con: + +```bash +docker compose exec backend python -m app.rcon_annual_rankings generate --year 2026 --server-key all-servers --metric kills --limit 30 --replace-existing +``` + +- No ejecutar comandos de metricas annual adicionales hasta que existan snapshots propios por metrica. + +Validaciones ejecutadas: + +- `node --check frontend/assets/js/ranking.js` +- Revision por busqueda de que `frontend/ranking.html` y `frontend/assets/js/ranking.js` ya no contienen `KPM`, `Actualizar ranking`, `ranking-year`, `ranking-submit`, `ranking-filter-note`, `El ranking expone` ni `listo en`. +- Intento de validacion HTTP local contra: + - `/api/ranking?timeframe=weekly&metric=kills&limit=20` + - `/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`; las tres peticiones fallaron con `No es posible conectar con el servidor remoto`. +- Revision de alcance con `git status --short` y `git diff --name-only`. + +Validaciones no ejecutadas: + +- No se ejecuto `compileall` ni `unittest` porque no se tocaron archivos backend. + +Tiempos obtenidos: + +- No se obtuvieron tiempos nuevos de endpoint porque el backend local no estaba escuchando. +- Intentos HTTP fallidos por conexion: + - weekly kills top 20: `2156.7 ms` + - monthly kills top 20: `2033.7 ms` + - annual kills top 20 2026: `2046.1 ms` +- Referencia previa de `TASK-213`: annual kills 2026 ~39 ms, weekly/monthly kills < 130 ms. + +Confirmacion de exclusiones: + +- No se ejecuto `ai-platform run`. +- No se hizo push. +- No se hizo commit. +- No se tocaron assets de armas. +- No se tocaron SVGs. +- No se modificaron imagenes fisicas. +- No se toco `ai/system-metrics.md`. +- No se reactivo Elo/MMR. +- No se reintrodujo Comunidad Hispana #03. +- No se incluyeron cambios previos no relacionados. + +## Change Budget + +- Archivos de producto modificados: 3. +- Sin cambios backend. +- El alcance se mantuvo dentro de la limpieza UI y documentacion de task. diff --git a/frontend/assets/css/styles.css b/frontend/assets/css/styles.css index c98fd00..3fb565e 100644 --- a/frontend/assets/css/styles.css +++ b/frontend/assets/css/styles.css @@ -1429,6 +1429,10 @@ h2 { margin-top: 20px; } +.ranking-secondary-action { + margin: 0 0 18px; +} + .ranking-form__grid { display: grid; gap: 12px; diff --git a/frontend/assets/js/ranking.js b/frontend/assets/js/ranking.js index 9e93d2b..82a27a5 100644 --- a/frontend/assets/js/ranking.js +++ b/frontend/assets/js/ranking.js @@ -5,9 +5,6 @@ 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"); const titleNode = document.getElementById("ranking-title"); const metaNode = document.getElementById("ranking-meta"); @@ -16,9 +13,8 @@ const metricHeadingNode = document.getElementById("ranking-metric-heading"); const kpmHeadingNode = document.getElementById("ranking-kpm-heading"); const emptyNode = document.getElementById("ranking-empty"); - const filterNoteNode = document.getElementById("ranking-filter-note"); - const currentYear = new Date().getUTCFullYear(); + const annualYear = 2026; const annualMetric = "kills"; const defaultMetric = "kills"; const defaultLimit = "20"; @@ -43,20 +39,13 @@ let currentRequestId = 0; let activeController = null; - if (yearInput) { - yearInput.value = String(currentYear); - } - applyInitialUrlState(); - toggleYearField(); syncMetricState(); - setRankingState("neutral", "Preparando ranking p\u00fablico..."); clearRankingSurface(); void loadRanking(); if (timeframeSelect) { timeframeSelect.addEventListener("change", () => { - toggleYearField(); syncMetricState(); updateUrlState(); void loadRanking(); @@ -74,20 +63,9 @@ }); }); - if (yearInput) { - yearInput.addEventListener("change", () => { - updateUrlState(); - if (String(timeframeSelect?.value || defaultTimeframe) === "annual") { - void loadRanking(); - } - }); - } - if (form) { form.addEventListener("submit", (event) => { event.preventDefault(); - updateUrlState(); - void loadRanking(); }); } @@ -95,26 +73,12 @@ if (!stateNode) { return; } - stateNode.textContent = message; + const normalizedMessage = String(message || "").trim(); + stateNode.hidden = !normalizedMessage; + stateNode.textContent = normalizedMessage; stateNode.className = `stats-state stats-state--${state}`; } - function setFilterNote(message, tone = "neutral") { - if (!filterNoteNode) { - return; - } - filterNoteNode.textContent = message; - 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"); @@ -143,12 +107,6 @@ ? String(limit) : defaultLimit; } - if (yearInput && year) { - const normalizedYear = Number.parseInt(year, 10); - if (Number.isFinite(normalizedYear) && normalizedYear > 0) { - yearInput.value = String(normalizedYear); - } - } if (params.has("limit") && !supportedLimits.includes(limit || "")) { setRankingState( @@ -156,15 +114,14 @@ "El limite del URL no es valido para esta interfaz. Se restauro el valor permitido por defecto.", ); } - } - - function toggleYearField() { - const isAnnual = timeframeSelect?.value === "annual"; - if (yearWrap) { - yearWrap.hidden = !isAnnual; - } - if (yearInput) { - yearInput.disabled = !isAnnual; + if (params.has("year")) { + const normalizedYear = Number.parseInt(year || "", 10); + if (Number.isFinite(normalizedYear) && normalizedYear !== annualYear) { + setRankingState( + "warning", + `El ranking anual de esta vista usa ${annualYear}. Se ajusto el a\u00f1o del URL.`, + ); + } } } @@ -181,19 +138,6 @@ if (isAnnual && metricSelect.value !== annualMetric) { metricSelect.value = annualMetric; } - - if (isAnnual) { - setFilterNote( - "El ranking anual sigue limitado a kills porque solo esa m\u00e9trica tiene lectura snapshot segura.", - "warning", - ); - return; - } - - setFilterNote( - "El ranking expone los resultados de los l\u00edderes. Para b\u00fasqueda individual usa Estad\u00edsticas.", - "neutral", - ); } function updateUrlState() { @@ -204,7 +148,7 @@ limit: String(limitSelect?.value || defaultLimit), }); if (searchParams.get("timeframe") === "annual") { - searchParams.set("year", String(yearInput?.value || currentYear)); + searchParams.set("year", String(annualYear)); } window.history.replaceState({}, "", `?${searchParams.toString()}`); } @@ -244,16 +188,10 @@ let year = null; if (timeframe === "annual") { - year = Number.parseInt(String(yearInput?.value || "").trim(), 10); - if (!Number.isFinite(year) || year <= 0) { - setRankingState("error", "El a\u00f1o solicitado no es v\u00e1lido."); - renderEmptyState("Corrige el a\u00f1o y vuelve a consultar el ranking anual."); - return; - } + year = annualYear; } setRankingState("loading", "Cargando ranking global..."); - setFormBusy(true); if (activeController) { activeController.abort(); @@ -306,7 +244,6 @@ ); } finally { if (requestId === currentRequestId) { - setFormBusy(false); activeController = null; } } @@ -326,24 +263,19 @@ normalizedMessage.includes("metric") && normalizedMessage.includes("annual") ) { - setRankingState("warning", "El ranking anual solo admite kills por ahora."); + setRankingState("warning", "La metrica anual solicitada no esta disponible."); renderEmptyState( - "Las m\u00e9tricas extra est\u00e1n disponibles en semanal y mensual. El ranking anual sigue limitado a kills mientras no existan snapshots seguros adicionales.", + "La consulta anual devolvio una metrica sin snapshot seguro para esta vista.", ); return; } if (statusCode === 400 && normalizedMessage.includes("metric")) { setRankingState("warning", "La m\u00e9trica solicitada no est\u00e1 soportada."); renderEmptyState( - "Usa kills, deaths, teamkills, partidas jugadas, K/D o KPM.", + "Usa kills, deaths, teamkills, partidas jugadas, K/D o Kills/partida.", ); return; } - if (statusCode === 400 && normalizedMessage.includes("year")) { - setRankingState("warning", "El ranking anual requiere un a\u00f1o v\u00e1lido."); - renderEmptyState("Define un a\u00f1o v\u00e1lido para consultar la lectura anual."); - return; - } if (statusCode === 400 && normalizedMessage.includes("timeframe")) { setRankingState("warning", "El periodo solicitado no esta soportado."); renderEmptyState("Usa un periodo semanal, mensual o anual."); @@ -403,10 +335,7 @@ return; } - setRankingState( - "ready", - `${labelForMetric(metric)} listo en ${labelForTimeframe(timeframe)} para ${labelForServer(serverId)}.`, - ); + setRankingState("ready", ""); if (tableBodyNode) { tableBodyNode.innerHTML = items.map((item) => renderRow(item, metric)).join(""); @@ -453,7 +382,11 @@ } function renderRow(item, metric) { - const kpm = formatKpm(item.kills_per_match, item.kills, item.matches_considered); + const killsPerMatch = formatKillsPerMatch( + item.kills_per_match, + item.kills, + item.matches_considered, + ); const hideKpmColumn = metric === "kills_per_match"; return ` @@ -471,7 +404,7 @@
Filtros de lectura
- El ranking expone los resultados de los líderes. Para búsqueda individual usa Estadísticas. -
+- Preparando ranking público... + Cargando ranking público...