diff --git a/ai/tasks/done/TASK-172-polish-stats-section-empty-states-and-copy.md b/ai/tasks/done/TASK-172-polish-stats-section-empty-states-and-copy.md
new file mode 100644
index 0000000..accdc91
--- /dev/null
+++ b/ai/tasks/done/TASK-172-polish-stats-section-empty-states-and-copy.md
@@ -0,0 +1,149 @@
+---
+id: TASK-172
+title: Pulir estados UX de la sección Stats
+status: done
+type: frontend
+team: Frontend Senior
+supporting_teams: [Backend Senior, Disenador grafico, Experto en interfaz]
+roadmap_item: foundation
+priority: medium
+---
+
+# TASK-172 - Pulir estados UX de la sección Stats
+
+## Goal
+
+Pulir la experiencia de usuario de la sección Stats en estados vacíos y errores, manteniendo la funcionalidad actual y el contrato backend existente.
+
+Preservar la identidad: comunidad hispana de HLL Vietnam con tono militar/Vietnam/táctico/sobrio y cambios progresivos del frontend.
+
+## Context
+
+Tras TASK-171, la sección Stats debe comunicar mejor:
+- estados de backend no disponible,
+- búsqueda sin resultados,
+- jugador sin estadísticas,
+- ranking anual listo pero vacío,
+- ranking anual sin generar,
+- y soporte inválido para métrica no soportada.
+
+No se modifica la lógica de datos ni se crean nuevos endpoints.
+
+## Steps
+
+1. Confirmar la lectura de los archivos listados en "Files to Read First".
+2. Revisar textos de estado actuales y su trazabilidad UX en `frontend/stats.html` y `frontend/assets/js/stats.js`.
+3. Ajustar mensajes para que sean claros, consistentes y diferenciados entre:
+ - backend no disponible
+ - búsqueda sin resultados
+ - jugador sin estadísticas
+ - ranking anual listo sin items
+ - ranking anual missing
+ - métrica anual no soportada
+4. Mantener HTML/CSS/JS vanilla y la estética actual.
+5. Mantener cualquier cambio de texto o estado dentro de los ficheros de Scope.
+6. Ejecutar validaciones mínimas de task: sintaxis, tests de integración y carga de frontend.
+
+## Files to Read First
+
+- AGENTS.md
+- ai/repo-context.md
+- ai/architecture-index.md
+- docs/stats-section-functional-plan.md
+- frontend/stats.html
+- frontend/assets/js/stats.js
+- frontend/assets/css/styles.css
+- ai/tasks/done/TASK-171-validate-stats-section-with-backend-data.md
+
+## Expected Files to Modify
+
+- frontend/stats.html
+- frontend/assets/js/stats.js
+- frontend/assets/css/styles.css
+
+## Constraints
+
+- No crear endpoints.
+- No modificar backend.
+- No modificar base de datos.
+- No cambiar la lógica de ranking.
+- No reactivar Elo/MMR.
+- No reintroducir Comunidad Hispana #03.
+- No tocar:
+ - frontend/assets/img/clans/bxb.png
+ - frontend/assets/js/partida-actual.js
+
+## Validation
+
+Antes de completar la task, validar:
+- `node --check frontend/assets/js/stats.js`
+- `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1`
+- servir frontend con `python -m http.server` y comprobar:
+ - `stats.html` -> 200
+ - `assets/js/stats.js` -> 200
+- revisar `git diff --name-only` y confirmar scope.
+
+## Outcome
+
+### Status
+
+Done
+
+### Files modified
+
+- frontend/stats.html
+- frontend/assets/js/stats.js
+
+### Estados y textos mejorados
+
+1. Estado inicial:
+ - Mensaje de inicio del buscador, ranking semanal/mensual y estado anual clarifica el flujo.
+2. Backend no disponible:
+ - Mensajes específicos para búsqueda/perfil y ranking anual.
+3. Busqueda sin resultados:
+ - Mensaje textual objetivo y sin ambiguedad.
+4. Jugador sin estadisticas:
+ - Mensaje de estado explicito y panel de resumen con warning.
+5. Ranking anual ready pero vacio:
+ - Distinguido de snapshot missing.
+6. Ranking anual missing:
+ - Mensaje que indica ausencia de snapshot y estado pendiente.
+7. Metrica anual no soportada:
+ - Detectada por 400 + mensaje de soporte invalid.
+
+### Mensajes de ayuda
+
+- Se agrega ayuda explicita para:
+ - resumen semanal,
+ - resumen mensual,
+ - ranking anual pre-computado.
+
+### Accesibilidad y usabilidad
+
+- Botones con `aria-label`.
+- Estados con role/status y mensajes persistentes en bloques dedicados.
+
+### Validaciones realizadas
+
+- `node --check frontend/assets/js/stats.js` (ok).
+- `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1` (ok).
+- Servidor local levantado con `python -m http.server` en 8080:
+ - `http://127.0.0.1:8080/stats.html` -> 200
+ - `http://127.0.0.1:8080/assets/js/stats.js` -> 200
+- `git diff --name-only` revisado para confirmar alcance de cambios.
+
+### Limitaciones detectadas
+
+- Mensajes y UI se mantienen en ASCII en algunos textos para evitar regresiones de encoding existente en entorno.
+- No se modificaron ni se revalidaron flujos visuales de backend offline via navegador.
+
+### Follow ups
+
+-- Revisar posibles mejoras tipograficas de textos de apoyo para corregir
+ errores menores de copy en futuras iteraciones.
+
+## Change Budget
+
+- Prefer fewer than 5 modified files.
+- Prefer changes under 200 lines when feasible.
+- Mantener cambios acotados y revisables.
diff --git a/frontend/assets/js/stats.js b/frontend/assets/js/stats.js
index 1834cec..0a17e24 100644
--- a/frontend/assets/js/stats.js
+++ b/frontend/assets/js/stats.js
@@ -16,18 +16,72 @@ document.addEventListener("DOMContentLoaded", () => {
const annualYearInput = document.getElementById("stats-annual-year");
const annualStateNode = document.getElementById("stats-annual-state");
const annualContentNode = document.getElementById("stats-annual-content");
+
const annualDefaultYear = new Date().getUTCFullYear();
const annualMetric = "kills";
+ const annualMetricSupportedOnly = "kills";
const annualLimit = 20;
const annualServerId = "all";
+ const messages = {
+ backendChecking: "Comprobando disponibilidad del backend",
+ backendOnline: "Backend operativo",
+ backendUnavailable: "Backend no disponible. Reintenta en unos segundos.",
+ backendUnavailableForSearch:
+ "Backend no disponible. No se pueden buscar jugadores ni cargar su perfil.",
+ backendUnavailableForAnnual:
+ "Backend no disponible. No se puede consultar el ranking anual.",
+ searchNoInput: "Escribe un nombre o ID para buscar.",
+ searchLoading: "Buscando jugadores...",
+ searchEmpty: "Sin resultados. Prueba con otro texto o ID.",
+ searchError:
+ "Error al buscar jugadores. Verifica backend y reintenta.",
+ searchReadyPrefix: "Se encontraron ",
+ searchReadySuffix:
+ " jugador(es). Selecciona uno para ver sus estadisticas.",
+ searchShortQueryHelp:
+ "Usa al menos 1 caracter para iniciar la busqueda.",
+ profileLoading: "Cargando estadisticas personales...",
+ profileNoStats:
+ "Jugador sin estadisticas suficientes para mostrar datos personales.",
+ profileError: "No fue posible cargar las estadisticas del jugador.",
+ annualLoading: "Cargando ranking anual...",
+ annualMissing:
+ "No hay snapshot generado para el ano solicitado. Mostrara estado missing hasta que exista.",
+ annualReadyEmpty:
+ "Snapshot anual listo pero sin datos para el ano y servidor seleccionado.",
+ annualUnsupportedMetric:
+ "La metrica anual solicitada no esta soportada en V1.",
+ annualMetricInvalid:
+ "Parametro de ranking anual invalido. Usa metric=kills en V1.",
+ annualReadyPrefix: "Ranking anual listo para",
+ weeklyPlaceholder:
+ "Sin datos semanales. El ranking semanal se actualiza al cargar un jugador.",
+ monthlyPlaceholder:
+ "Sin datos mensuales. El ranking mensual se actualiza al cargar un jugador.",
+ profileReadyTitle: "Perfil personal",
+ profileEmptyTitle: "Selecciona un jugador para ver sus estadisticas.",
+ weeklySummaryHelp:
+ "Resumen semanal: muestra la ventana activa del jugador.",
+ monthlySummaryHelp:
+ "Resumen mensual: muestra la ventana activa del jugador.",
+ };
+
let isBackendOnline = false;
- setBackendState("Comprobando disponibilidad del backend", false);
+ if (searchHelpNode) {
+ searchHelpNode.textContent =
+ "Usa el buscador para encontrar un jugador. Al seleccionar uno veras resumen semanal y mensual. " +
+ "El ranking anual se consulta por separado con el ano indicado.";
+ }
+
+ setBackendState(messages.backendChecking, false);
if (annualYearInput) {
annualYearInput.value = String(annualDefaultYear);
}
+ clearProfilePanel(false);
refreshBackendHealth();
+
if (annualForm && annualYearInput) {
annualForm.addEventListener("submit", (event) => {
event.preventDefault();
@@ -40,7 +94,7 @@ document.addEventListener("DOMContentLoaded", () => {
event.preventDefault();
const query = (searchInput.value || "").trim();
if (!query) {
- setSearchState("error", "Escribe un nombre o ID para buscar.");
+ setSearchState("error", messages.searchNoInput);
return;
}
void searchPlayers(query);
@@ -58,12 +112,44 @@ document.addEventListener("DOMContentLoaded", () => {
}
function markAsBackendUnavailable() {
- setBackendState("Backend no disponible", false);
+ setBackendState(messages.backendUnavailable, false);
if (searchStateNode) {
- searchStateNode.textContent = "Backend no disponible. Reintenta en unos segundos.";
+ searchStateNode.textContent = messages.backendUnavailableForSearch;
searchStateNode.className = "stats-state stats-state--error";
}
- setAnnualState("error", "Backend no disponible. No se puede cargar ranking anual.");
+ setAnnualState("error", messages.backendUnavailableForAnnual);
+ }
+
+ function setSearchState(state, message) {
+ if (!searchStateNode) {
+ return;
+ }
+ searchStateNode.textContent = message;
+ searchStateNode.className = `stats-state stats-state--${state}`;
+ }
+
+ function setAnnualState(state, message) {
+ if (!annualStateNode) {
+ return;
+ }
+ annualStateNode.textContent = message;
+ annualStateNode.className = `stats-state stats-state--${state}`;
+ }
+
+ function setProfileState(state, message) {
+ if (!profileStateNode || !profilePanel) {
+ return;
+ }
+ profilePanel.hidden = false;
+ profileStateNode.textContent = message;
+ profileStateNode.className = `stats-state stats-state--${state}`;
+ summaryGrid.innerHTML = "";
+ if (weeklySummaryNode) {
+ weeklySummaryNode.textContent = "Cargando ...";
+ }
+ if (monthlySummaryNode) {
+ monthlySummaryNode.textContent = "Cargando ...";
+ }
}
async function refreshBackendHealth() {
@@ -73,13 +159,16 @@ document.addEventListener("DOMContentLoaded", () => {
throw new Error(`Health request failed with ${response.status}`);
}
const payload = await response.json();
- if (payload && payload.status === "ok") {
- setBackendState("Backend operativo", true);
- setAnnualState("loading", "Cargando ranking anual...");
- void loadAnnualRanking();
- return;
+ if (!payload || payload.status !== "ok") {
+ throw new Error("Unexpected health payload");
}
- throw new Error("Unexpected health payload");
+
+ setBackendState(messages.backendOnline, true);
+ setAnnualState(
+ "neutral",
+ "Backend disponible. Selecciona un ano para cargar el ranking anual (kills).",
+ );
+ void loadAnnualRanking();
} catch (error) {
markAsBackendUnavailable();
console.warn("Backend health check failed", error);
@@ -90,14 +179,17 @@ document.addEventListener("DOMContentLoaded", () => {
if (!resultListNode) {
return;
}
-
+ if (!query) {
+ setSearchState("error", messages.searchShortQueryHelp);
+ return;
+ }
if (!isBackendOnline) {
markAsBackendUnavailable();
return;
}
- setSearchState("loading", "Buscando jugadores...");
- clearProfilePanel();
+ setSearchState("loading", messages.searchLoading);
+ clearProfilePanel(true);
resultListNode.innerHTML = "";
try {
@@ -110,16 +202,19 @@ document.addEventListener("DOMContentLoaded", () => {
const payload = await searchResponse.json();
if (!payload || payload.status !== "ok") {
- throw new Error(payload?.message || "Respuesta de búsqueda inválida");
+ throw new Error(payload?.message || "Respuesta de busqueda invalida");
}
const items = normalizeArray(payload.data?.items);
if (!items.length) {
- setSearchState("empty", "Sin resultados.");
+ setSearchState("empty", messages.searchEmpty);
return;
}
- setSearchState("ready", `Se encontraron ${items.length} jugadores.`);
+ setSearchState(
+ "ready",
+ `${messages.searchReadyPrefix}${items.length}${messages.searchReadySuffix}`,
+ );
resultListNode.innerHTML = items.map(renderResultItem).join("");
resultListNode.querySelectorAll("[data-player-id]").forEach((button) => {
button.addEventListener("click", () => {
@@ -132,7 +227,7 @@ document.addEventListener("DOMContentLoaded", () => {
} catch (error) {
console.warn("Player search failed", error);
markAsBackendUnavailable();
- setSearchState("error", "Error al buscar. Verifica backend y reintenta.");
+ setSearchState("error", messages.searchError);
}
}
@@ -151,7 +246,7 @@ document.addEventListener("DOMContentLoaded", () => {
return;
}
- setAnnualState("loading", "Cargando ranking anual...");
+ setAnnualState("loading", messages.annualLoading);
annualContentNode.innerHTML = "";
try {
@@ -164,6 +259,21 @@ document.addEventListener("DOMContentLoaded", () => {
const annualResponse = await fetch(annualUrl);
if (!annualResponse.ok) {
+ const annualErrorPayload = await safeParseJson(annualResponse);
+ const annualErrorMessage = String(
+ annualErrorPayload?.message || annualErrorPayload?.detail || "",
+ ).toLowerCase();
+
+ if (isAnnualMetricUnsupportedError(annualResponse.status, annualErrorMessage)) {
+ setAnnualState("warning", messages.annualUnsupportedMetric);
+ return;
+ }
+
+ if (annualResponse.status === 400 && annualErrorMessage.includes("metric")) {
+ setAnnualState("warning", messages.annualMetricInvalid);
+ return;
+ }
+
throw new Error(`Annual ranking request failed with ${annualResponse.status}`);
}
@@ -171,6 +281,7 @@ document.addEventListener("DOMContentLoaded", () => {
if (!payload || payload.status !== "ok") {
throw new Error(payload?.message || "Respuesta de ranking anual invalida");
}
+
renderAnnualRanking(payload.data || {});
} catch (error) {
console.warn("Annual ranking failed", error);
@@ -180,6 +291,16 @@ document.addEventListener("DOMContentLoaded", () => {
}
}
+ function isAnnualMetricUnsupportedError(statusCode, message) {
+ const normalized = String(message || "").toLowerCase();
+ return (
+ statusCode === 400 &&
+ normalized.includes("metric") &&
+ normalized.includes("unsupported") &&
+ annualMetric.toLowerCase() === annualMetricSupportedOnly
+ );
+ }
+
function resolveAnnualYear() {
const normalized = Number.parseInt(String(annualYearInput?.value || "").trim(), 10);
if (!Number.isFinite(normalized) || normalized <= 0) {
@@ -199,24 +320,22 @@ document.addEventListener("DOMContentLoaded", () => {
const serverId = String(data.server_id || annualServerId);
const sourceMatches = safeInt(data.source_matches_count, 0);
const generatedAt = formatDateTime(data.generated_at);
+ const year = safeInt(data.year, annualDefaultYear);
+ const metric = escapeHtml(String(data.metric || annualMetric));
if (snapshotStatus !== "ready" || !items.length) {
const isReadyButEmpty = snapshotStatus === "ready" && !items.length;
setAnnualState(
"neutral",
- isReadyButEmpty
- ? "Ranking anual listo pero sin resultados para el ano seleccionado."
- : "No hay ranking anual generado para el ano seleccionado.",
+ isReadyButEmpty ? messages.annualReadyEmpty : messages.annualMissing,
);
- if (!items.length) {
- annualContentNode.innerHTML = "";
- }
+ annualContentNode.innerHTML = "";
return;
}
setAnnualState(
"neutral",
- `Ranking anual listo para ${serverId}. Generado: ${generatedAt || "sin marca de tiempo"}.`,
+ `${messages.annualReadyPrefix} ${serverId}, ano ${year}, metrica ${metric}`,
);
annualContentNode.innerHTML = `
@@ -224,7 +343,7 @@ document.addEventListener("DOMContentLoaded", () => {
Top ${limit} anual
@@ -245,11 +364,12 @@ document.addEventListener("DOMContentLoaded", () => {
const deaths = safeInt(item.deaths, 0);
const teamkills = safeInt(item.teamkills, 0);
const kd = safeDecimal(item.kd_ratio, 2, "0.00");
+
return `
#${rank} ${playerName} (ID: ${playerId})
- Valor: ${metricValue} · Partidas: ${matches}
- Kills: ${kills} · Deaths: ${deaths} · Teamkills: ${teamkills} · K/D: ${kd}
+ Valor: ${metricValue} - Partidas: ${matches}
+ Kills: ${kills} - Deaths: ${deaths} - Teamkills: ${teamkills} - K/D: ${kd}
`;
})
@@ -272,13 +392,18 @@ document.addEventListener("DOMContentLoaded", () => {
return `
-