Clean ranking UI and rename false KPM metric
This commit is contained in:
236
ai/tasks/done/TASK-216-clean-ranking-ui-and-fix-kpm-metric.md
Normal file
236
ai/tasks/done/TASK-216-clean-ranking-ui-and-fix-kpm-metric.md
Normal file
@@ -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.
|
||||||
@@ -1429,6 +1429,10 @@ h2 {
|
|||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ranking-secondary-action {
|
||||||
|
margin: 0 0 18px;
|
||||||
|
}
|
||||||
|
|
||||||
.ranking-form__grid {
|
.ranking-form__grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
|||||||
@@ -5,9 +5,6 @@
|
|||||||
const serverSelect = document.getElementById("ranking-server");
|
const serverSelect = document.getElementById("ranking-server");
|
||||||
const metricSelect = document.getElementById("ranking-metric");
|
const metricSelect = document.getElementById("ranking-metric");
|
||||||
const limitSelect = document.getElementById("ranking-limit");
|
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 stateNode = document.getElementById("ranking-state");
|
||||||
const titleNode = document.getElementById("ranking-title");
|
const titleNode = document.getElementById("ranking-title");
|
||||||
const metaNode = document.getElementById("ranking-meta");
|
const metaNode = document.getElementById("ranking-meta");
|
||||||
@@ -16,9 +13,8 @@
|
|||||||
const metricHeadingNode = document.getElementById("ranking-metric-heading");
|
const metricHeadingNode = document.getElementById("ranking-metric-heading");
|
||||||
const kpmHeadingNode = document.getElementById("ranking-kpm-heading");
|
const kpmHeadingNode = document.getElementById("ranking-kpm-heading");
|
||||||
const emptyNode = document.getElementById("ranking-empty");
|
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 annualMetric = "kills";
|
||||||
const defaultMetric = "kills";
|
const defaultMetric = "kills";
|
||||||
const defaultLimit = "20";
|
const defaultLimit = "20";
|
||||||
@@ -43,20 +39,13 @@
|
|||||||
let currentRequestId = 0;
|
let currentRequestId = 0;
|
||||||
let activeController = null;
|
let activeController = null;
|
||||||
|
|
||||||
if (yearInput) {
|
|
||||||
yearInput.value = String(currentYear);
|
|
||||||
}
|
|
||||||
|
|
||||||
applyInitialUrlState();
|
applyInitialUrlState();
|
||||||
toggleYearField();
|
|
||||||
syncMetricState();
|
syncMetricState();
|
||||||
setRankingState("neutral", "Preparando ranking p\u00fablico...");
|
|
||||||
clearRankingSurface();
|
clearRankingSurface();
|
||||||
void loadRanking();
|
void loadRanking();
|
||||||
|
|
||||||
if (timeframeSelect) {
|
if (timeframeSelect) {
|
||||||
timeframeSelect.addEventListener("change", () => {
|
timeframeSelect.addEventListener("change", () => {
|
||||||
toggleYearField();
|
|
||||||
syncMetricState();
|
syncMetricState();
|
||||||
updateUrlState();
|
updateUrlState();
|
||||||
void loadRanking();
|
void loadRanking();
|
||||||
@@ -74,20 +63,9 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
if (yearInput) {
|
|
||||||
yearInput.addEventListener("change", () => {
|
|
||||||
updateUrlState();
|
|
||||||
if (String(timeframeSelect?.value || defaultTimeframe) === "annual") {
|
|
||||||
void loadRanking();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (form) {
|
if (form) {
|
||||||
form.addEventListener("submit", (event) => {
|
form.addEventListener("submit", (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
updateUrlState();
|
|
||||||
void loadRanking();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,26 +73,12 @@
|
|||||||
if (!stateNode) {
|
if (!stateNode) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
stateNode.textContent = message;
|
const normalizedMessage = String(message || "").trim();
|
||||||
|
stateNode.hidden = !normalizedMessage;
|
||||||
|
stateNode.textContent = normalizedMessage;
|
||||||
stateNode.className = `stats-state stats-state--${state}`;
|
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() {
|
function applyInitialUrlState() {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const timeframe = params.get("timeframe");
|
const timeframe = params.get("timeframe");
|
||||||
@@ -143,12 +107,6 @@
|
|||||||
? String(limit)
|
? String(limit)
|
||||||
: defaultLimit;
|
: 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 || "")) {
|
if (params.has("limit") && !supportedLimits.includes(limit || "")) {
|
||||||
setRankingState(
|
setRankingState(
|
||||||
@@ -156,15 +114,14 @@
|
|||||||
"El limite del URL no es valido para esta interfaz. Se restauro el valor permitido por defecto.",
|
"El limite del URL no es valido para esta interfaz. Se restauro el valor permitido por defecto.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
if (params.has("year")) {
|
||||||
|
const normalizedYear = Number.parseInt(year || "", 10);
|
||||||
function toggleYearField() {
|
if (Number.isFinite(normalizedYear) && normalizedYear !== annualYear) {
|
||||||
const isAnnual = timeframeSelect?.value === "annual";
|
setRankingState(
|
||||||
if (yearWrap) {
|
"warning",
|
||||||
yearWrap.hidden = !isAnnual;
|
`El ranking anual de esta vista usa ${annualYear}. Se ajusto el a\u00f1o del URL.`,
|
||||||
}
|
);
|
||||||
if (yearInput) {
|
}
|
||||||
yearInput.disabled = !isAnnual;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,19 +138,6 @@
|
|||||||
if (isAnnual && metricSelect.value !== annualMetric) {
|
if (isAnnual && metricSelect.value !== annualMetric) {
|
||||||
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() {
|
function updateUrlState() {
|
||||||
@@ -204,7 +148,7 @@
|
|||||||
limit: String(limitSelect?.value || defaultLimit),
|
limit: String(limitSelect?.value || defaultLimit),
|
||||||
});
|
});
|
||||||
if (searchParams.get("timeframe") === "annual") {
|
if (searchParams.get("timeframe") === "annual") {
|
||||||
searchParams.set("year", String(yearInput?.value || currentYear));
|
searchParams.set("year", String(annualYear));
|
||||||
}
|
}
|
||||||
window.history.replaceState({}, "", `?${searchParams.toString()}`);
|
window.history.replaceState({}, "", `?${searchParams.toString()}`);
|
||||||
}
|
}
|
||||||
@@ -244,16 +188,10 @@
|
|||||||
|
|
||||||
let year = null;
|
let year = null;
|
||||||
if (timeframe === "annual") {
|
if (timeframe === "annual") {
|
||||||
year = Number.parseInt(String(yearInput?.value || "").trim(), 10);
|
year = annualYear;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setRankingState("loading", "Cargando ranking global...");
|
setRankingState("loading", "Cargando ranking global...");
|
||||||
setFormBusy(true);
|
|
||||||
|
|
||||||
if (activeController) {
|
if (activeController) {
|
||||||
activeController.abort();
|
activeController.abort();
|
||||||
@@ -306,7 +244,6 @@
|
|||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
if (requestId === currentRequestId) {
|
if (requestId === currentRequestId) {
|
||||||
setFormBusy(false);
|
|
||||||
activeController = null;
|
activeController = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -326,24 +263,19 @@
|
|||||||
normalizedMessage.includes("metric") &&
|
normalizedMessage.includes("metric") &&
|
||||||
normalizedMessage.includes("annual")
|
normalizedMessage.includes("annual")
|
||||||
) {
|
) {
|
||||||
setRankingState("warning", "El ranking anual solo admite kills por ahora.");
|
setRankingState("warning", "La metrica anual solicitada no esta disponible.");
|
||||||
renderEmptyState(
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (statusCode === 400 && normalizedMessage.includes("metric")) {
|
if (statusCode === 400 && normalizedMessage.includes("metric")) {
|
||||||
setRankingState("warning", "La m\u00e9trica solicitada no est\u00e1 soportada.");
|
setRankingState("warning", "La m\u00e9trica solicitada no est\u00e1 soportada.");
|
||||||
renderEmptyState(
|
renderEmptyState(
|
||||||
"Usa kills, deaths, teamkills, partidas jugadas, K/D o KPM.",
|
"Usa kills, deaths, teamkills, partidas jugadas, K/D o Kills/partida.",
|
||||||
);
|
);
|
||||||
return;
|
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")) {
|
if (statusCode === 400 && normalizedMessage.includes("timeframe")) {
|
||||||
setRankingState("warning", "El periodo solicitado no esta soportado.");
|
setRankingState("warning", "El periodo solicitado no esta soportado.");
|
||||||
renderEmptyState("Usa un periodo semanal, mensual o anual.");
|
renderEmptyState("Usa un periodo semanal, mensual o anual.");
|
||||||
@@ -403,10 +335,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setRankingState(
|
setRankingState("ready", "");
|
||||||
"ready",
|
|
||||||
`${labelForMetric(metric)} listo en ${labelForTimeframe(timeframe)} para ${labelForServer(serverId)}.`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (tableBodyNode) {
|
if (tableBodyNode) {
|
||||||
tableBodyNode.innerHTML = items.map((item) => renderRow(item, metric)).join("");
|
tableBodyNode.innerHTML = items.map((item) => renderRow(item, metric)).join("");
|
||||||
@@ -453,7 +382,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderRow(item, metric) {
|
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";
|
const hideKpmColumn = metric === "kills_per_match";
|
||||||
|
|
||||||
return `
|
return `
|
||||||
@@ -471,7 +404,7 @@
|
|||||||
<td>${safeInt(item.teamkills, 0)}</td>
|
<td>${safeInt(item.teamkills, 0)}</td>
|
||||||
<td>${safeInt(item.matches_considered, 0)}</td>
|
<td>${safeInt(item.matches_considered, 0)}</td>
|
||||||
<td>${safeDecimal(item.kd_ratio, 2, "0.00")}</td>
|
<td>${safeDecimal(item.kd_ratio, 2, "0.00")}</td>
|
||||||
${hideKpmColumn ? "" : `<td>${kpm}</td>`}
|
${hideKpmColumn ? "" : `<td>${killsPerMatch}</td>`}
|
||||||
</tr>
|
</tr>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -495,7 +428,7 @@
|
|||||||
|
|
||||||
function labelForWindow(data) {
|
function labelForWindow(data) {
|
||||||
if (String(data.timeframe || "") === "annual") {
|
if (String(data.timeframe || "") === "annual") {
|
||||||
return `A\u00f1o ${safeInt(data.year, currentYear)}`;
|
return `A\u00f1o ${safeInt(data.year, annualYear)}`;
|
||||||
}
|
}
|
||||||
return String(data.window_label || data.window_kind || "Periodo activo");
|
return String(data.window_label || data.window_kind || "Periodo activo");
|
||||||
}
|
}
|
||||||
@@ -527,7 +460,7 @@
|
|||||||
teamkills: "Teamkills",
|
teamkills: "Teamkills",
|
||||||
matches_considered: "Partidas jugadas",
|
matches_considered: "Partidas jugadas",
|
||||||
kd_ratio: "K/D",
|
kd_ratio: "K/D",
|
||||||
kills_per_match: "KPM",
|
kills_per_match: "Kills/partida",
|
||||||
};
|
};
|
||||||
return labels[metric] || "Kills";
|
return labels[metric] || "Kills";
|
||||||
}
|
}
|
||||||
@@ -558,7 +491,7 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatKpm(rawKillsPerMatch, rawKills, rawMatches) {
|
function formatKillsPerMatch(rawKillsPerMatch, rawKills, rawMatches) {
|
||||||
const directValue = Number(rawKillsPerMatch);
|
const directValue = Number(rawKillsPerMatch);
|
||||||
if (Number.isFinite(directValue)) {
|
if (Number.isFinite(directValue)) {
|
||||||
return safeDecimal(directValue, 2, "0.00");
|
return safeDecimal(directValue, 2, "0.00");
|
||||||
|
|||||||
@@ -61,9 +61,11 @@
|
|||||||
<p class="eyebrow eyebrow--section">Filtros de lectura</p>
|
<p class="eyebrow eyebrow--section">Filtros de lectura</p>
|
||||||
<h2>Top público por periodo</h2>
|
<h2>Top público por periodo</h2>
|
||||||
</div>
|
</div>
|
||||||
<p class="panel__intro panel__intro--tight">
|
<div class="ranking-secondary-action">
|
||||||
El ranking expone los resultados de los líderes. Para búsqueda individual usa Estadísticas.
|
<a class="secondary-button secondary-button--compact" href="./stats.html">
|
||||||
</p>
|
Buscar jugador en Estadísticas
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
<form class="ranking-form" id="ranking-form">
|
<form class="ranking-form" id="ranking-form">
|
||||||
<div class="ranking-form__grid">
|
<div class="ranking-form__grid">
|
||||||
<label class="stats-search-form__label" for="ranking-timeframe">
|
<label class="stats-search-form__label" for="ranking-timeframe">
|
||||||
@@ -90,7 +92,7 @@
|
|||||||
<option value="teamkills">Teamkills</option>
|
<option value="teamkills">Teamkills</option>
|
||||||
<option value="matches_considered">Partidas jugadas</option>
|
<option value="matches_considered">Partidas jugadas</option>
|
||||||
<option value="kd_ratio">K/D</option>
|
<option value="kd_ratio">K/D</option>
|
||||||
<option value="kills_per_match">KPM</option>
|
<option value="kills_per_match">Kills/partida</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label class="stats-search-form__label" for="ranking-limit">
|
<label class="stats-search-form__label" for="ranking-limit">
|
||||||
@@ -104,32 +106,10 @@
|
|||||||
<option value="100">Top 100</option>
|
<option value="100">Top 100</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label class="stats-search-form__label" for="ranking-year" id="ranking-year-wrap" hidden>
|
|
||||||
Año
|
|
||||||
<input
|
|
||||||
class="stats-search-input"
|
|
||||||
id="ranking-year"
|
|
||||||
name="year"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
step="1"
|
|
||||||
inputmode="numeric"
|
|
||||||
aria-describedby="ranking-state"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<p class="ranking-form__note" id="ranking-filter-note">
|
|
||||||
Consulta líderes públicos y cambia de periodo o servidor sin salir de la página.
|
|
||||||
</p>
|
|
||||||
<div class="hero__actions ranking-form__actions">
|
|
||||||
<button class="discord-button" id="ranking-submit" type="submit">Actualizar ranking</button>
|
|
||||||
<a class="secondary-button secondary-button--compact" href="./stats.html">
|
|
||||||
Buscar jugador en Estadísticas
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<p class="stats-state stats-state--neutral" id="ranking-state" role="status" aria-live="polite">
|
<p class="stats-state stats-state--neutral" id="ranking-state" role="status" aria-live="polite">
|
||||||
Preparando ranking público...
|
Cargando ranking público...
|
||||||
</p>
|
</p>
|
||||||
<h3 class="ranking-results-title" id="ranking-title">Tabla activa</h3>
|
<h3 class="ranking-results-title" id="ranking-title">Tabla activa</h3>
|
||||||
<div class="ranking-meta" id="ranking-meta"></div>
|
<div class="ranking-meta" id="ranking-meta"></div>
|
||||||
@@ -145,7 +125,7 @@
|
|||||||
<th>Teamkills</th>
|
<th>Teamkills</th>
|
||||||
<th>Partidas</th>
|
<th>Partidas</th>
|
||||||
<th>K/D</th>
|
<th>K/D</th>
|
||||||
<th id="ranking-kpm-heading">KPM</th>
|
<th id="ranking-kpm-heading">Kills/partida</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="ranking-table-body"></tbody>
|
<tbody id="ranking-table-body"></tbody>
|
||||||
|
|||||||
Reference in New Issue
Block a user