Polish Stats empty states and copy
This commit is contained in:
@@ -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.
|
||||||
@@ -16,18 +16,72 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
const annualYearInput = document.getElementById("stats-annual-year");
|
const annualYearInput = document.getElementById("stats-annual-year");
|
||||||
const annualStateNode = document.getElementById("stats-annual-state");
|
const annualStateNode = document.getElementById("stats-annual-state");
|
||||||
const annualContentNode = document.getElementById("stats-annual-content");
|
const annualContentNode = document.getElementById("stats-annual-content");
|
||||||
|
|
||||||
const annualDefaultYear = new Date().getUTCFullYear();
|
const annualDefaultYear = new Date().getUTCFullYear();
|
||||||
const annualMetric = "kills";
|
const annualMetric = "kills";
|
||||||
|
const annualMetricSupportedOnly = "kills";
|
||||||
const annualLimit = 20;
|
const annualLimit = 20;
|
||||||
const annualServerId = "all";
|
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;
|
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) {
|
if (annualYearInput) {
|
||||||
annualYearInput.value = String(annualDefaultYear);
|
annualYearInput.value = String(annualDefaultYear);
|
||||||
}
|
}
|
||||||
|
clearProfilePanel(false);
|
||||||
refreshBackendHealth();
|
refreshBackendHealth();
|
||||||
|
|
||||||
if (annualForm && annualYearInput) {
|
if (annualForm && annualYearInput) {
|
||||||
annualForm.addEventListener("submit", (event) => {
|
annualForm.addEventListener("submit", (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -40,7 +94,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const query = (searchInput.value || "").trim();
|
const query = (searchInput.value || "").trim();
|
||||||
if (!query) {
|
if (!query) {
|
||||||
setSearchState("error", "Escribe un nombre o ID para buscar.");
|
setSearchState("error", messages.searchNoInput);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void searchPlayers(query);
|
void searchPlayers(query);
|
||||||
@@ -58,12 +112,44 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function markAsBackendUnavailable() {
|
function markAsBackendUnavailable() {
|
||||||
setBackendState("Backend no disponible", false);
|
setBackendState(messages.backendUnavailable, false);
|
||||||
if (searchStateNode) {
|
if (searchStateNode) {
|
||||||
searchStateNode.textContent = "Backend no disponible. Reintenta en unos segundos.";
|
searchStateNode.textContent = messages.backendUnavailableForSearch;
|
||||||
searchStateNode.className = "stats-state stats-state--error";
|
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() {
|
async function refreshBackendHealth() {
|
||||||
@@ -73,13 +159,16 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
throw new Error(`Health request failed with ${response.status}`);
|
throw new Error(`Health request failed with ${response.status}`);
|
||||||
}
|
}
|
||||||
const payload = await response.json();
|
const payload = await response.json();
|
||||||
if (payload && payload.status === "ok") {
|
if (!payload || payload.status !== "ok") {
|
||||||
setBackendState("Backend operativo", true);
|
|
||||||
setAnnualState("loading", "Cargando ranking anual...");
|
|
||||||
void loadAnnualRanking();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
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) {
|
} catch (error) {
|
||||||
markAsBackendUnavailable();
|
markAsBackendUnavailable();
|
||||||
console.warn("Backend health check failed", error);
|
console.warn("Backend health check failed", error);
|
||||||
@@ -90,14 +179,17 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
if (!resultListNode) {
|
if (!resultListNode) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!query) {
|
||||||
|
setSearchState("error", messages.searchShortQueryHelp);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!isBackendOnline) {
|
if (!isBackendOnline) {
|
||||||
markAsBackendUnavailable();
|
markAsBackendUnavailable();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setSearchState("loading", "Buscando jugadores...");
|
setSearchState("loading", messages.searchLoading);
|
||||||
clearProfilePanel();
|
clearProfilePanel(true);
|
||||||
resultListNode.innerHTML = "";
|
resultListNode.innerHTML = "";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -110,16 +202,19 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
|
|
||||||
const payload = await searchResponse.json();
|
const payload = await searchResponse.json();
|
||||||
if (!payload || payload.status !== "ok") {
|
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);
|
const items = normalizeArray(payload.data?.items);
|
||||||
if (!items.length) {
|
if (!items.length) {
|
||||||
setSearchState("empty", "Sin resultados.");
|
setSearchState("empty", messages.searchEmpty);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setSearchState("ready", `Se encontraron ${items.length} jugadores.`);
|
setSearchState(
|
||||||
|
"ready",
|
||||||
|
`${messages.searchReadyPrefix}${items.length}${messages.searchReadySuffix}`,
|
||||||
|
);
|
||||||
resultListNode.innerHTML = items.map(renderResultItem).join("");
|
resultListNode.innerHTML = items.map(renderResultItem).join("");
|
||||||
resultListNode.querySelectorAll("[data-player-id]").forEach((button) => {
|
resultListNode.querySelectorAll("[data-player-id]").forEach((button) => {
|
||||||
button.addEventListener("click", () => {
|
button.addEventListener("click", () => {
|
||||||
@@ -132,7 +227,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("Player search failed", error);
|
console.warn("Player search failed", error);
|
||||||
markAsBackendUnavailable();
|
markAsBackendUnavailable();
|
||||||
setSearchState("error", "Error al buscar. Verifica backend y reintenta.");
|
setSearchState("error", messages.searchError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,7 +246,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setAnnualState("loading", "Cargando ranking anual...");
|
setAnnualState("loading", messages.annualLoading);
|
||||||
annualContentNode.innerHTML = "";
|
annualContentNode.innerHTML = "";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -164,6 +259,21 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
|
|
||||||
const annualResponse = await fetch(annualUrl);
|
const annualResponse = await fetch(annualUrl);
|
||||||
if (!annualResponse.ok) {
|
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}`);
|
throw new Error(`Annual ranking request failed with ${annualResponse.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,6 +281,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
if (!payload || payload.status !== "ok") {
|
if (!payload || payload.status !== "ok") {
|
||||||
throw new Error(payload?.message || "Respuesta de ranking anual invalida");
|
throw new Error(payload?.message || "Respuesta de ranking anual invalida");
|
||||||
}
|
}
|
||||||
|
|
||||||
renderAnnualRanking(payload.data || {});
|
renderAnnualRanking(payload.data || {});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("Annual ranking failed", 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() {
|
function resolveAnnualYear() {
|
||||||
const normalized = Number.parseInt(String(annualYearInput?.value || "").trim(), 10);
|
const normalized = Number.parseInt(String(annualYearInput?.value || "").trim(), 10);
|
||||||
if (!Number.isFinite(normalized) || normalized <= 0) {
|
if (!Number.isFinite(normalized) || normalized <= 0) {
|
||||||
@@ -199,24 +320,22 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
const serverId = String(data.server_id || annualServerId);
|
const serverId = String(data.server_id || annualServerId);
|
||||||
const sourceMatches = safeInt(data.source_matches_count, 0);
|
const sourceMatches = safeInt(data.source_matches_count, 0);
|
||||||
const generatedAt = formatDateTime(data.generated_at);
|
const generatedAt = formatDateTime(data.generated_at);
|
||||||
|
const year = safeInt(data.year, annualDefaultYear);
|
||||||
|
const metric = escapeHtml(String(data.metric || annualMetric));
|
||||||
|
|
||||||
if (snapshotStatus !== "ready" || !items.length) {
|
if (snapshotStatus !== "ready" || !items.length) {
|
||||||
const isReadyButEmpty = snapshotStatus === "ready" && !items.length;
|
const isReadyButEmpty = snapshotStatus === "ready" && !items.length;
|
||||||
setAnnualState(
|
setAnnualState(
|
||||||
"neutral",
|
"neutral",
|
||||||
isReadyButEmpty
|
isReadyButEmpty ? messages.annualReadyEmpty : messages.annualMissing,
|
||||||
? "Ranking anual listo pero sin resultados para el ano seleccionado."
|
|
||||||
: "No hay ranking anual generado para el ano seleccionado.",
|
|
||||||
);
|
);
|
||||||
if (!items.length) {
|
|
||||||
annualContentNode.innerHTML = "";
|
annualContentNode.innerHTML = "";
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setAnnualState(
|
setAnnualState(
|
||||||
"neutral",
|
"neutral",
|
||||||
`Ranking anual listo para ${serverId}. Generado: ${generatedAt || "sin marca de tiempo"}.`,
|
`${messages.annualReadyPrefix} ${serverId}, ano ${year}, metrica ${metric}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
annualContentNode.innerHTML = `
|
annualContentNode.innerHTML = `
|
||||||
@@ -224,7 +343,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
<p class="stats-summary-title">Top ${limit} anual</p>
|
<p class="stats-summary-title">Top ${limit} anual</p>
|
||||||
<div class="stats-annual-meta">
|
<div class="stats-annual-meta">
|
||||||
<p><strong>Servidor:</strong> ${escapeHtml(serverId)}</p>
|
<p><strong>Servidor:</strong> ${escapeHtml(serverId)}</p>
|
||||||
<p><strong>Metrica:</strong> kills</p>
|
<p><strong>Metrica:</strong> ${escapeHtml(metric)}</p>
|
||||||
<p><strong>Partidas fuente:</strong> ${safeInt(sourceMatches, 0)}</p>
|
<p><strong>Partidas fuente:</strong> ${safeInt(sourceMatches, 0)}</p>
|
||||||
<p><strong>Actualizado:</strong> ${escapeHtml(generatedAt || "No disponible")}</p>
|
<p><strong>Actualizado:</strong> ${escapeHtml(generatedAt || "No disponible")}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -245,11 +364,12 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
const deaths = safeInt(item.deaths, 0);
|
const deaths = safeInt(item.deaths, 0);
|
||||||
const teamkills = safeInt(item.teamkills, 0);
|
const teamkills = safeInt(item.teamkills, 0);
|
||||||
const kd = safeDecimal(item.kd_ratio, 2, "0.00");
|
const kd = safeDecimal(item.kd_ratio, 2, "0.00");
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<article class="stats-annual-item">
|
<article class="stats-annual-item">
|
||||||
<p><strong>#${rank}</strong> ${playerName} <span class="stats-annual-sub">(ID: ${playerId})</span></p>
|
<p><strong>#${rank}</strong> ${playerName} <span class="stats-annual-sub">(ID: ${playerId})</span></p>
|
||||||
<p><strong>Valor:</strong> ${metricValue} · <strong>Partidas:</strong> ${matches}</p>
|
<p><strong>Valor:</strong> ${metricValue} - <strong>Partidas:</strong> ${matches}</p>
|
||||||
<p><strong>Kills:</strong> ${kills} · <strong>Deaths:</strong> ${deaths} · <strong>Teamkills:</strong> ${teamkills} · <strong>K/D:</strong> ${kd}</p>
|
<p><strong>Kills:</strong> ${kills} - <strong>Deaths:</strong> ${deaths} - <strong>Teamkills:</strong> ${teamkills} - <strong>K/D:</strong> ${kd}</p>
|
||||||
</article>
|
</article>
|
||||||
`;
|
`;
|
||||||
})
|
})
|
||||||
@@ -272,13 +392,18 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
|
|
||||||
return `
|
return `
|
||||||
<article class="stats-result-item">
|
<article class="stats-result-item">
|
||||||
<button class="stats-result-item__button" type="button" data-player-id="${playerId}">
|
<button
|
||||||
|
class="stats-result-item__button"
|
||||||
|
type="button"
|
||||||
|
data-player-id="${playerId}"
|
||||||
|
aria-label="Cargar perfil de ${playerName}"
|
||||||
|
>
|
||||||
<div class="stats-result-item__main">
|
<div class="stats-result-item__main">
|
||||||
<p class="stats-result-item__name">${playerName}</p>
|
<p class="stats-result-item__name">${playerName}</p>
|
||||||
<p class="stats-result-item__meta">ID: ${playerId}</p>
|
<p class="stats-result-item__meta">ID: ${playerId}</p>
|
||||||
</div>
|
</div>
|
||||||
<p class="stats-result-item__metrics">
|
<p class="stats-result-item__metrics">
|
||||||
Partidas: ${matches} · Última aparición: ${lastSeen}
|
Partidas: ${matches} - Ultima aparicion: ${lastSeen}
|
||||||
</p>
|
</p>
|
||||||
${serversSeen ? `<p class="stats-result-item__meta">Servidores: ${serversSeen}</p>` : ""}
|
${serversSeen ? `<p class="stats-result-item__meta">Servidores: ${serversSeen}</p>` : ""}
|
||||||
</button>
|
</button>
|
||||||
@@ -292,8 +417,10 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setProfileState("loading", "Cargando estadísticas personales...");
|
setProfileState("loading", messages.profileLoading);
|
||||||
clearProfilePanel();
|
if (!profilePanel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
@@ -305,19 +432,19 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
|
|
||||||
const payload = await response.json();
|
const payload = await response.json();
|
||||||
if (!payload || payload.status !== "ok") {
|
if (!payload || payload.status !== "ok") {
|
||||||
throw new Error(payload?.message || "Respuesta de perfil inválida");
|
throw new Error(payload?.message || "Respuesta de perfil invalida");
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = payload.data || {};
|
const data = payload.data || {};
|
||||||
profilePanel.hidden = false;
|
profilePanel.hidden = false;
|
||||||
profileTitle.textContent = `Perfil personal`;
|
profileTitle.textContent = messages.profileReadyTitle;
|
||||||
const playerName = String(data.player_name || "Sin nombre");
|
const playerName = String(data.player_name || "Sin nombre");
|
||||||
const playerIdText = String(data.player_id || playerId);
|
const playerIdText = String(data.player_id || playerId);
|
||||||
const hasStats = Number(data.matches_considered || 0) > 0;
|
const hasStats = Number(data.matches_considered || 0) > 0;
|
||||||
|
|
||||||
profileStateNode.textContent = hasStats
|
profileStateNode.textContent = hasStats
|
||||||
? `${playerName} (${playerIdText})`
|
? `${playerName} (${playerIdText})`
|
||||||
: `Jugador sin estadísticas registradas: ${playerName} (${playerIdText})`;
|
: `${messages.profileNoStats} (${playerIdText})`;
|
||||||
profileStateNode.className = hasStats
|
profileStateNode.className = hasStats
|
||||||
? "stats-state stats-state--neutral"
|
? "stats-state stats-state--neutral"
|
||||||
: "stats-state stats-state--warning";
|
: "stats-state stats-state--warning";
|
||||||
@@ -352,72 +479,52 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
"Mensual",
|
"Mensual",
|
||||||
);
|
);
|
||||||
|
|
||||||
setBackendState("Backend operativo", true);
|
setBackendState(messages.backendOnline, true);
|
||||||
isBackendOnline = true;
|
isBackendOnline = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("Player profile failed", error);
|
console.warn("Player profile failed", error);
|
||||||
markAsBackendUnavailable();
|
markAsBackendUnavailable();
|
||||||
|
if (profilePanel) {
|
||||||
profilePanel.hidden = false;
|
profilePanel.hidden = false;
|
||||||
profileTitle.textContent = "Perfil personal";
|
}
|
||||||
profileStateNode.textContent = "No fue posible cargar las estadísticas del jugador.";
|
profileTitle.textContent = messages.profileReadyTitle;
|
||||||
|
profileStateNode.textContent = messages.profileError;
|
||||||
profileStateNode.className = "stats-state stats-state--error";
|
profileStateNode.className = "stats-state stats-state--error";
|
||||||
summaryGrid.innerHTML = "";
|
summaryGrid.innerHTML = "";
|
||||||
weeklySummaryNode.textContent = "Sin datos de semana disponibles.";
|
if (weeklySummaryNode) {
|
||||||
monthlySummaryNode.textContent = "Sin datos de mes disponibles.";
|
weeklySummaryNode.textContent = messages.weeklyPlaceholder;
|
||||||
|
}
|
||||||
|
if (monthlySummaryNode) {
|
||||||
|
monthlySummaryNode.textContent = messages.monthlyPlaceholder;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setSearchState(state, message) {
|
function clearProfilePanel(openPanel) {
|
||||||
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 = "";
|
|
||||||
weeklySummaryNode.textContent = "Cargando...";
|
|
||||||
monthlySummaryNode.textContent = "Cargando...";
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearProfilePanel() {
|
|
||||||
if (!profilePanel || !summaryGrid || !profileStateNode) {
|
if (!profilePanel || !summaryGrid || !profileStateNode) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (openPanel) {
|
||||||
profilePanel.hidden = true;
|
profilePanel.hidden = true;
|
||||||
|
}
|
||||||
summaryGrid.innerHTML = "";
|
summaryGrid.innerHTML = "";
|
||||||
if (weeklySummaryNode) {
|
if (weeklySummaryNode) {
|
||||||
weeklySummaryNode.textContent = "Sin datos de semana disponibles.";
|
weeklySummaryNode.textContent = messages.weeklyPlaceholder;
|
||||||
}
|
}
|
||||||
if (monthlySummaryNode) {
|
if (monthlySummaryNode) {
|
||||||
monthlySummaryNode.textContent = "Sin datos de mes disponibles.";
|
monthlySummaryNode.textContent = messages.monthlyPlaceholder;
|
||||||
}
|
}
|
||||||
profileStateNode.textContent = "Selecciona un jugador para ver sus estadisticas.";
|
profileStateNode.textContent = messages.profileEmptyTitle;
|
||||||
profileStateNode.className = "stats-state stats-state--neutral";
|
profileStateNode.className = "stats-state stats-state--neutral";
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatRankingSummary(ranking, timeframeLabel) {
|
function formatRankingSummary(ranking, timeframeLabel) {
|
||||||
if (!ranking || !Number.isFinite(safeParseNumber(ranking.ranking_position))) {
|
if (!ranking || !Number.isFinite(safeParseNumber(ranking.ranking_position))) {
|
||||||
return `Sin ranking ${timeframeLabel} registrado para este jugador.`;
|
return `Sin ranking ${timeframeLabel.toLowerCase()} registrado para este jugador.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rank = safeInt(ranking.ranking_position, 0);
|
const rank = safeInt(ranking.ranking_position, 0);
|
||||||
const metric = escapeHtml(String(ranking.metric || "kills"));
|
const metric = escapeHtml(String(ranking.metric || annualMetric));
|
||||||
const windowLabel = escapeHtml(String(ranking.window_kind || ""));
|
const windowLabel = escapeHtml(String(ranking.window_kind || ""));
|
||||||
const windowStart = formatDateTime(ranking.window_start);
|
const windowStart = formatDateTime(ranking.window_start);
|
||||||
const windowEnd = formatDateTime(ranking.window_end);
|
const windowEnd = formatDateTime(ranking.window_end);
|
||||||
@@ -426,7 +533,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
: `Ventana: ${windowStart} - ${windowEnd}`;
|
: `Ventana: ${windowStart} - ${windowEnd}`;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<p><strong>Posición:</strong> ${rank}</p>
|
<p><strong>Posicion:</strong> ${rank}</p>
|
||||||
<p><strong>Metric:</strong> ${metric}</p>
|
<p><strong>Metric:</strong> ${metric}</p>
|
||||||
<p>${windowRange}</p>
|
<p>${windowRange}</p>
|
||||||
`;
|
`;
|
||||||
@@ -456,6 +563,16 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
return Number.isFinite(parsed) ? parsed : Number.NaN;
|
return Number.isFinite(parsed) ? parsed : Number.NaN;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function safeParseJson(response) {
|
||||||
|
if (!response) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
.json()
|
||||||
|
.then((parsed) => parsed)
|
||||||
|
.catch(() => null);
|
||||||
|
}
|
||||||
|
|
||||||
function formatDateTime(value) {
|
function formatDateTime(value) {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return "No disponible";
|
return "No disponible";
|
||||||
|
|||||||
@@ -22,8 +22,8 @@
|
|||||||
<span class="hero__title-accent">Personales</span>
|
<span class="hero__title-accent">Personales</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p class="hero__text">
|
<p class="hero__text">
|
||||||
Busca un jugador por nombre o ID y revisa sus resultados semanales y
|
Busca un jugador por nombre o ID y revisa sus resultados semanales, mensuales
|
||||||
mensuales con base en datos materializados.
|
y ranking anual con base en datos materializados.
|
||||||
</p>
|
</p>
|
||||||
<p class="status-chip status-chip--fallback" id="stats-backend-state">
|
<p class="status-chip status-chip--fallback" id="stats-backend-state">
|
||||||
Comprobando disponibilidad del backend
|
Comprobando disponibilidad del backend
|
||||||
@@ -48,7 +48,8 @@
|
|||||||
<h2>Buscar por nombre o ID</h2>
|
<h2>Buscar por nombre o ID</h2>
|
||||||
</div>
|
</div>
|
||||||
<p class="panel__intro panel__intro--tight" id="stats-search-help">
|
<p class="panel__intro panel__intro--tight" id="stats-search-help">
|
||||||
Usa minimo 1 caracter para comenzar la consulta.
|
Usa al menos 1 caracter para buscar. Al seleccionar un jugador se cargan sus
|
||||||
|
stats y ranking semanal y mensual. El ranking anual se consulta por separado.
|
||||||
</p>
|
</p>
|
||||||
<form class="stats-search-form" id="stats-search-form">
|
<form class="stats-search-form" id="stats-search-form">
|
||||||
<label class="stats-search-form__label" for="stats-search-input">
|
<label class="stats-search-form__label" for="stats-search-input">
|
||||||
@@ -62,14 +63,15 @@
|
|||||||
name="query"
|
name="query"
|
||||||
placeholder="Ej: Rambo o 76561198000000000"
|
placeholder="Ej: Rambo o 76561198000000000"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
|
aria-label="Texto de busqueda de jugadores"
|
||||||
/>
|
/>
|
||||||
<button class="discord-button" type="submit">
|
<button class="discord-button" type="submit" aria-label="Buscar jugador por nombre o ID">
|
||||||
Buscar
|
Buscar jugador
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<p class="stats-state stats-state--neutral" id="stats-search-state" role="status" aria-live="polite">
|
<p class="stats-state stats-state--neutral" id="stats-search-state" role="status" aria-live="polite">
|
||||||
Esperando consulta.
|
Estado inicial: usa el buscador para encontrar jugadores.
|
||||||
</p>
|
</p>
|
||||||
<div class="stats-result-list" id="stats-result-list" aria-live="polite"></div>
|
<div class="stats-result-list" id="stats-result-list" aria-live="polite"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -82,17 +84,21 @@
|
|||||||
<h2 id="stats-profile-title">Perfil personal</h2>
|
<h2 id="stats-profile-title">Perfil personal</h2>
|
||||||
</div>
|
</div>
|
||||||
<p class="panel__intro panel__intro--tight" id="stats-profile-state">
|
<p class="panel__intro panel__intro--tight" id="stats-profile-state">
|
||||||
Selecciona un jugador para ver sus estadisticas.
|
Selecciona un jugador para ver su panel personal.
|
||||||
</p>
|
</p>
|
||||||
<div class="stats-summary-grid" id="stats-summary-grid"></div>
|
<div class="stats-summary-grid" id="stats-summary-grid"></div>
|
||||||
<div class="stats-ranking-grid">
|
<div class="stats-ranking-grid">
|
||||||
<article class="stats-summary-card">
|
<article class="stats-summary-card">
|
||||||
<p class="stats-summary-title">Resumen semanal</p>
|
<p class="stats-summary-title">Resumen semanal</p>
|
||||||
<div id="stats-weekly-summary">Sin datos de semana disponibles.</div>
|
<div id="stats-weekly-summary">
|
||||||
|
Sin datos semanales. El ranking semanal se actualiza al cargar un jugador.
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
<article class="stats-summary-card">
|
<article class="stats-summary-card">
|
||||||
<p class="stats-summary-title">Resumen mensual</p>
|
<p class="stats-summary-title">Resumen mensual</p>
|
||||||
<div id="stats-monthly-summary">Sin datos de mes disponibles.</div>
|
<div id="stats-monthly-summary">
|
||||||
|
Sin datos mensuales. El ranking mensual se actualiza al cargar un jugador.
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -105,7 +111,7 @@
|
|||||||
<h2>Top 20 anual</h2>
|
<h2>Top 20 anual</h2>
|
||||||
</div>
|
</div>
|
||||||
<p class="panel__intro panel__intro--tight">
|
<p class="panel__intro panel__intro--tight">
|
||||||
El ranking anual consume snapshots precomputados de backend para evitar recalculo en cada vista.
|
El ranking anual usa snapshots pre-computados para evitar recalcular toda la temporada en cada consulta.
|
||||||
</p>
|
</p>
|
||||||
<form class="stats-annual-form" id="stats-annual-form">
|
<form class="stats-annual-form" id="stats-annual-form">
|
||||||
<label class="stats-search-form__label" for="stats-annual-year">Ano</label>
|
<label class="stats-search-form__label" for="stats-annual-year">Ano</label>
|
||||||
@@ -116,15 +122,16 @@
|
|||||||
type="number"
|
type="number"
|
||||||
min="2019"
|
min="2019"
|
||||||
value=""
|
value=""
|
||||||
|
aria-label="Ano del ranking anual"
|
||||||
aria-describedby="stats-annual-state"
|
aria-describedby="stats-annual-state"
|
||||||
/>
|
/>
|
||||||
<button class="discord-button" type="submit">
|
<button class="discord-button" type="submit" aria-label="Cargar ranking anual">
|
||||||
Cargar ranking
|
Cargar ranking anual
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<p class="stats-state stats-state--neutral" id="stats-annual-state" role="status" aria-live="polite">
|
<p class="stats-state stats-state--neutral" id="stats-annual-state" role="status" aria-live="polite">
|
||||||
Esperando ano para cargar ranking anual.
|
Esperando ano para cargar el ranking anual (Top 20 por kills).
|
||||||
</p>
|
</p>
|
||||||
<div class="stats-annual-table-wrap" id="stats-annual-content" aria-live="polite"></div>
|
<div class="stats-annual-table-wrap" id="stats-annual-content" aria-live="polite"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -136,4 +143,3 @@
|
|||||||
<script src="./assets/js/stats.js"></script>
|
<script src="./assets/js/stats.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user