From 49cb4778f7ecc279ed79ee893877949509f92f97 Mon Sep 17 00:00:00 2001 From: devRaGonSa Date: Mon, 8 Jun 2026 12:35:13 +0200 Subject: [PATCH] Add Stats frontend page --- .../done/TASK-165-add-stats-frontend-page.md | 126 +++++++ frontend/assets/css/styles.css | 172 +++++++++ frontend/assets/js/stats.js | 339 ++++++++++++++++++ frontend/historico.html | 3 + frontend/index.html | 3 + frontend/stats.html | 123 +++++++ 6 files changed, 766 insertions(+) create mode 100644 ai/tasks/done/TASK-165-add-stats-frontend-page.md create mode 100644 frontend/assets/js/stats.js create mode 100644 frontend/stats.html diff --git a/ai/tasks/done/TASK-165-add-stats-frontend-page.md b/ai/tasks/done/TASK-165-add-stats-frontend-page.md new file mode 100644 index 0000000..c400163 --- /dev/null +++ b/ai/tasks/done/TASK-165-add-stats-frontend-page.md @@ -0,0 +1,126 @@ +--- +id: TASK-165 +title: Add stats frontend page +status: done +type: frontend +team: Frontend Senior +supporting_teams: [] +roadmap_item: foundation +priority: medium +--- + +# TASK-165 - Add stats frontend page + +## Goal + +Implement the first frontend version of the new `Stats` section using existing backend endpoints. + +## Context + +TASK-163 added backend support for player search and personal stats payloads under `/api/stats`. + +- `GET /api/stats/players/search?q=` +- `GET /api/stats/players/{player_id}` + +This task only covers frontend consumption and does not modify backend code. + +## Steps + +1. Read all files listed in `Files to Read First` before creating files. +2. Create `frontend/stats.html` with the V1 section layout. +3. Create `frontend/assets/js/stats.js` with vanilla JS state and API handlers: + - search call + - result rendering + - player selection + - profile request +4. Add Stats navigation entry in the shared shell used by landing/history without breaking existing sections. +5. Wire the following state blocks: + - loading + - error + - sin resultados + - jugador sin estadisticas + - backend no disponible +6. Add reserved annual ranking top 20 block (UI placeholder only, no backend call). +7. Update CSS only if required to support the new page. +8. Validate manually (and with integration tests when available) and record results in task outcome. + +## Files to Read First + +- `AGENTS.md` +- `ai/repo-context.md` +- `ai/architecture-index.md` +- `docs/stats-section-functional-plan.md` +- `docs/stats-frontend-integration-plan.md` +- `frontend/index.html` +- `frontend/historico.html` +- `frontend/assets/css/styles.css` +- `frontend/assets/js/main.js` +- `backend/app/routes.py` +- `backend/app/payloads.py` + +## Expected Files to Modify + +- `frontend/stats.html` +- `frontend/assets/js/stats.js` +- `frontend/index.html` +- `frontend/historico.html` +- `frontend/assets/css/styles.css` (only if necessary) + +## Constraints + +- No backend changes. +- No migrations. +- No annual ranking real endpoint consumption in V1. +- No changes to historical workers. +- No Elo/MMR reactivation. +- Do not touch `frontend/assets/js/partida-actual.js`. +- Do not touch `frontend/assets/img/clans/bxb.png`. +- Do not reintroduce Comunidad Hispana #03. +- Keep HTML/CSS/JS vanilla (no frameworks). +- Preserve existing HLL Vietnam identity (military, Vietnam, tactical, sober). +- Keep scope small and reviewable. + +## Validation + +Before task completion: + +- Validate `frontend/stats.html` loads and executes without visible JS runtime errors. +- Validate search flow against local backend (if running): + - query -> backend search + - select player -> personal profile +- Validate empty/error/unsupported states, including backend unreachable. +- Validate annual placeholder is rendered without backend call. +- Run `scripts/run-integration-tests.ps1` when applicable. +- Run `git diff --name-only` and confirm only expected files changed for this task. +- Confirm `frontend/assets/js/partida-actual.js` and `frontend/assets/img/clans/bxb.png` were not touched. + +## Outcome + +- Archivos modificados: + - `frontend/stats.html` + - `frontend/assets/js/stats.js` + - `frontend/index.html` + - `frontend/historico.html` + - `frontend/assets/css/styles.css` +- Endpoints consumidos: + - `GET /api/stats/players/search?q=` + - `GET /api/stats/players/{player_id}` +- Validaciones realizadas: + - Validación de carga estática: + - se sirvió `frontend/stats.html` desde `python -m http.server` (status 200), + - se sirvió `frontend/assets/js/stats.js` (status 200). + - Validación del flujo con backend local: + - `GET /health` en `http://127.0.0.1:8000` no respondió (backend sin servicio en este momento), + - se dejó implementada la ruta de error `Backend no disponible`, + - no fue posible validar query corta, query normal y no-resultados contra backend en esta pasada. + - Validación de tests: + - `scripts/run-integration-tests.ps1` ejecutado y finalizó con éxito. +- Limitaciones conocidas: + - backend local no estaba levantado al momento de validación de integración, + - no se validó ranking anual real ni endpoint de ese dominio por restricción de alcance V1. +- Siguiente task recomendada: `TASK-166` diseño/implementación BDD de ranking anual top 20 (snapshot). + +## Change Budget + +- Prefer fewer than 5 modified files (allowing one extra if CSS adjustments are needed). +- Keep changes concise and aligned with V1 scope. diff --git a/frontend/assets/css/styles.css b/frontend/assets/css/styles.css index a3699f2..f395b04 100644 --- a/frontend/assets/css/styles.css +++ b/frontend/assets/css/styles.css @@ -916,6 +916,170 @@ h2 { min-width: 180px; } +.stats__hero-actions { + margin-top: 8px; +} + +.stats-search-form { + margin: 0 0 14px; + display: grid; + gap: 10px; +} + +.stats-search-form__label { + margin: 0; + color: var(--text-soft); + font-size: 0.86rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.stats-search-form__fields { + display: grid; + grid-template-columns: 1fr auto; + gap: 10px; + align-items: center; +} + +.stats-search-input { + min-height: 52px; + padding: 0 16px; + border: 1px solid rgba(210, 182, 118, 0.3); + border-radius: 12px; + background: linear-gradient(180deg, rgba(19, 24, 16, 0.96), rgba(10, 13, 9, 0.98)); + color: var(--text); + font-size: 1rem; + outline: none; +} + +.stats-search-input:focus-visible { + border-color: rgba(210, 182, 118, 0.6); + box-shadow: 0 0 0 2px rgba(210, 182, 118, 0.2); +} + +.stats-state { + margin: 0 0 14px; + padding: 0.75rem 1rem; + border-radius: 12px; + border: 1px solid rgba(159, 168, 141, 0.24); + background: rgba(16, 21, 15, 0.72); + color: var(--text-soft); +} + +.stats-state--neutral { + border-color: rgba(159, 168, 141, 0.28); +} + +.stats-state--loading { + border-color: rgba(183, 201, 125, 0.45); + color: var(--accent-strong); +} + +.stats-state--error { + border-color: rgba(210, 182, 118, 0.45); + color: var(--accent-warm); +} + +.stats-state--warning { + border-color: rgba(210, 182, 118, 0.35); + color: #e2d7a9; +} + +.stats-result-list { + display: grid; + gap: 10px; +} + +.stats-result-item__button { + width: 100%; + text-align: left; + border: 1px solid rgba(159, 168, 141, 0.26); + border-radius: 14px; + padding: 14px 16px; + background: linear-gradient(180deg, rgba(28, 34, 25, 0.9), rgba(12, 15, 11, 0.95)); + color: var(--text); + cursor: pointer; + transition: + border-color 160ms ease, + transform 160ms ease, + box-shadow 160ms ease; +} + +.stats-result-item__button:hover, +.stats-result-item__button:focus-visible { + border-color: rgba(210, 182, 118, 0.5); + transform: translateY(-1px); + box-shadow: 0 8px 22px rgba(0, 0, 0, 0.22); +} + +.stats-result-item__main { + display: grid; + gap: 2px; +} + +.stats-result-item__name { + margin: 0; + font-weight: 700; +} + +.stats-result-item__meta { + margin: 4px 0 0; + color: var(--muted); + font-size: 0.82rem; +} + +.stats-result-item__metrics { + margin: 10px 0 0; + color: var(--text-soft); + font-size: 0.85rem; +} + +.stats-summary-grid { + display: grid; + gap: 12px; + margin-bottom: 16px; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr)); +} + +.stats-summary-card { + margin: 0; + border: 1px solid rgba(159, 168, 141, 0.2); + border-radius: 16px; + padding: 14px 16px; + background: + linear-gradient(180deg, rgba(28, 34, 25, 0.88), rgba(12, 15, 11, 0.96)); +} + +.stats-summary-card--placeholder { + border-color: rgba(210, 182, 118, 0.26); + color: var(--accent-warm); +} + +.stats-summary-title { + margin: 0 0 10px; + color: var(--accent-strong); + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.stats-summary-card p { + margin: 0 0 8px; +} + +.stats-summary-card p:last-child { + margin-bottom: 0; +} + +.stats-ranking-grid { + display: grid; + gap: 12px; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr)); +} + +.stats-hero .status-chip { + width: fit-content; +} + .server-action-link--disabled { border-color: rgba(159, 168, 141, 0.18); background: linear-gradient(180deg, rgba(42, 46, 39, 0.5), rgba(19, 22, 17, 0.7)); @@ -975,6 +1139,14 @@ h2 { font-size: 0.98rem; } + .stats-search-form__fields { + grid-template-columns: 1fr; + } + + .stats-search-form__fields .discord-button { + width: 100%; + } + .logo-frame { min-height: 180px; padding: 16px 18px; diff --git a/frontend/assets/js/stats.js b/frontend/assets/js/stats.js new file mode 100644 index 0000000..d700997 --- /dev/null +++ b/frontend/assets/js/stats.js @@ -0,0 +1,339 @@ +document.addEventListener("DOMContentLoaded", () => { + const backendBaseUrl = document.body.dataset.backendBaseUrl || "http://127.0.0.1:8000"; + const searchForm = document.getElementById("stats-search-form"); + const searchInput = document.getElementById("stats-search-input"); + const searchStateNode = document.getElementById("stats-search-state"); + const searchHelpNode = document.getElementById("stats-search-help"); + const resultListNode = document.getElementById("stats-result-list"); + const backendStateNode = document.getElementById("stats-backend-state"); + const profilePanel = document.getElementById("stats-profile-panel"); + const profileTitle = document.getElementById("stats-profile-title"); + const profileStateNode = document.getElementById("stats-profile-state"); + const summaryGrid = document.getElementById("stats-summary-grid"); + const weeklySummaryNode = document.getElementById("stats-weekly-summary"); + const monthlySummaryNode = document.getElementById("stats-monthly-summary"); + + let isBackendOnline = false; + + setBackendState("Comprobando disponibilidad del backend", false); + refreshBackendHealth(); + + if (searchForm && searchInput) { + searchForm.addEventListener("submit", (event) => { + event.preventDefault(); + const query = (searchInput.value || "").trim(); + if (!query) { + setSearchState("error", "Escribe un nombre o ID para buscar."); + return; + } + void searchPlayers(query); + }); + } + + function setBackendState(label, isOnline) { + isBackendOnline = isOnline; + if (!backendStateNode) { + return; + } + backendStateNode.textContent = label; + backendStateNode.classList.toggle("status-chip--ok", isOnline); + backendStateNode.classList.toggle("status-chip--fallback", !isOnline); + } + + function markAsBackendUnavailable() { + setBackendState("Backend no disponible", false); + if (searchStateNode) { + searchStateNode.textContent = "Backend no disponible. Reintenta en unos segundos."; + searchStateNode.className = "stats-state stats-state--error"; + } + } + + async function refreshBackendHealth() { + try { + const response = await fetch(`${backendBaseUrl}/health`); + if (!response.ok) { + throw new Error(`Health request failed with ${response.status}`); + } + const payload = await response.json(); + if (payload && payload.status === "ok") { + setBackendState("Backend operativo", true); + return; + } + throw new Error("Unexpected health payload"); + } catch (error) { + markAsBackendUnavailable(); + console.warn("Backend health check failed", error); + } + } + + async function searchPlayers(query) { + if (!resultListNode) { + return; + } + + if (!isBackendOnline) { + markAsBackendUnavailable(); + return; + } + + setSearchState("loading", "Buscando jugadores..."); + clearProfilePanel(); + resultListNode.innerHTML = ""; + + try { + const searchResponse = await fetch( + `${backendBaseUrl}/api/stats/players/search?q=${encodeURIComponent(query)}`, + ); + if (!searchResponse.ok) { + throw new Error(`Search request failed with ${searchResponse.status}`); + } + + const payload = await searchResponse.json(); + if (!payload || payload.status !== "ok") { + throw new Error(payload?.message || "Respuesta de búsqueda inválida"); + } + + const items = normalizeArray(payload.data?.items); + if (!items.length) { + setSearchState("empty", "Sin resultados."); + return; + } + + setSearchState("ready", `Se encontraron ${items.length} jugadores.`); + resultListNode.innerHTML = items.map(renderResultItem).join(""); + resultListNode.querySelectorAll("[data-player-id]").forEach((button) => { + button.addEventListener("click", () => { + const playerId = button.getAttribute("data-player-id"); + if (playerId) { + void loadPlayerProfile(playerId); + } + }); + }); + } catch (error) { + console.warn("Player search failed", error); + markAsBackendUnavailable(); + setSearchState("error", "Error al buscar. Verifica backend y reintenta."); + } + } + + function renderResultItem(item) { + const playerId = escapeHtml(String(item.player_id || "")); + const playerName = escapeHtml(String(item.player_name || "Jugador sin nombre")); + const matches = Number.isFinite(Number(item.matches_considered)) + ? Number(item.matches_considered) + : 0; + const lastSeen = formatDateTime(item.last_seen_at); + const serversSeen = Array.isArray(item.servers_seen) + ? item.servers_seen + .filter(Boolean) + .map((server) => escapeHtml(String(server))) + .join(", ") + : ""; + + return ` +
+ +
+ `; + } + + async function loadPlayerProfile(playerId) { + if (!isBackendOnline) { + markAsBackendUnavailable(); + return; + } + + setProfileState("loading", "Cargando estadísticas personales..."); + clearProfilePanel(); + + try { + const response = await fetch( + `${backendBaseUrl}/api/stats/players/${encodeURIComponent(playerId)}?timeframe=weekly`, + ); + if (!response.ok) { + throw new Error(`Profile request failed with ${response.status}`); + } + + const payload = await response.json(); + if (!payload || payload.status !== "ok") { + throw new Error(payload?.message || "Respuesta de perfil inválida"); + } + + const data = payload.data || {}; + profilePanel.hidden = false; + profileTitle.textContent = `Perfil personal`; + const playerName = String(data.player_name || "Sin nombre"); + const playerIdText = String(data.player_id || playerId); + const hasStats = Number(data.matches_considered || 0) > 0; + + profileStateNode.textContent = hasStats + ? `${playerName} (${playerIdText})` + : `Jugador sin estadísticas registradas: ${playerName} (${playerIdText})`; + profileStateNode.className = hasStats + ? "stats-state stats-state--neutral" + : "stats-state stats-state--warning"; + + summaryGrid.innerHTML = ` +
+

Identidad

+

Jugador: ${escapeHtml(playerName)}

+

ID: ${escapeHtml(playerIdText)}

+

Partidas: ${safeInt(data.matches_considered, 0)}

+
+
+

Totales

+

Kills: ${safeInt(data.kills, 0)}

+

Deaths: ${safeInt(data.deaths, 0)}

+

Teamkills: ${safeInt(data.teamkills, 0)}

+
+
+

Ratios

+

K/D: ${safeDecimal(data.kd_ratio, 2, "0.00")}

+

Kills/partida: ${safeDecimal(data.kills_per_match, 2, "0.00")}

+

Deaths/partida: ${safeDecimal(data.deaths_per_match, 2, "0.00")}

+
+ `; + + weeklySummaryNode.innerHTML = formatRankingSummary( + data.weekly_ranking, + "Semanal", + ); + monthlySummaryNode.innerHTML = formatRankingSummary( + data.monthly_ranking, + "Mensual", + ); + + setBackendState("Backend operativo", true); + isBackendOnline = true; + } catch (error) { + console.warn("Player profile failed", error); + markAsBackendUnavailable(); + profilePanel.hidden = false; + profileTitle.textContent = "Perfil personal"; + profileStateNode.textContent = "No fue posible cargar las estadísticas del jugador."; + profileStateNode.className = "stats-state stats-state--error"; + summaryGrid.innerHTML = ""; + weeklySummaryNode.textContent = "Sin datos de semana disponibles."; + monthlySummaryNode.textContent = "Sin datos de mes disponibles."; + } + } + + function setSearchState(state, message) { + if (!searchStateNode) { + return; + } + searchStateNode.textContent = message; + searchStateNode.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) { + return; + } + profilePanel.hidden = true; + summaryGrid.innerHTML = ""; + if (weeklySummaryNode) { + weeklySummaryNode.textContent = "Sin datos de semana disponibles."; + } + if (monthlySummaryNode) { + monthlySummaryNode.textContent = "Sin datos de mes disponibles."; + } + profileStateNode.textContent = "Selecciona un jugador para ver sus estadisticas."; + profileStateNode.className = "stats-state stats-state--neutral"; + } + + function formatRankingSummary(ranking, timeframeLabel) { + if (!ranking || !Number.isFinite(safeParseNumber(ranking.ranking_position))) { + return `Sin ranking ${timeframeLabel} registrado para este jugador.`; + } + + const rank = safeInt(ranking.ranking_position, 0); + const metric = escapeHtml(String(ranking.metric || "kills")); + const windowLabel = escapeHtml(String(ranking.window_kind || "")); + const windowStart = formatDateTime(ranking.window_start); + const windowEnd = formatDateTime(ranking.window_end); + const windowRange = windowLabel + ? `${windowLabel}: ${windowStart} - ${windowEnd}` + : `Ventana: ${windowStart} - ${windowEnd}`; + + return ` +

Posición: ${rank}

+

Metric: ${metric}

+

${windowRange}

+ `; + } + + function safeInt(value, fallback = 0) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + return fallback; + } + return parsed; + } + + function safeDecimal(value, maximumFractionDigits, fallback = "0") { + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + return fallback; + } + return parsed.toLocaleString("es-ES", { + minimumFractionDigits: maximumFractionDigits, + maximumFractionDigits, + }); + } + + function safeParseNumber(value) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : Number.NaN; + } + + function formatDateTime(value) { + if (!value) { + return "No disponible"; + } + const parsedDate = new Date(String(value)); + if (Number.isNaN(parsedDate.getTime())) { + return "No disponible"; + } + + return new Intl.DateTimeFormat("es-ES", { + dateStyle: "short", + timeStyle: "short", + }).format(parsedDate); + } + + function normalizeArray(items) { + return Array.isArray(items) ? items : []; + } + + function escapeHtml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); + } +}); + diff --git a/frontend/historico.html b/frontend/historico.html index 74127d2..df49bab 100644 --- a/frontend/historico.html +++ b/frontend/historico.html @@ -20,6 +20,9 @@ VOLVER INICIO + + STATS +

Historico propio

diff --git a/frontend/index.html b/frontend/index.html index 3b79278..7a6d936 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -48,6 +48,9 @@ > Unirse al Discord + + Stats + Ver historico propio diff --git a/frontend/stats.html b/frontend/stats.html new file mode 100644 index 0000000..dad3dbb --- /dev/null +++ b/frontend/stats.html @@ -0,0 +1,123 @@ + + + + + + + Stats - HLL Vietnam + + + +
+
+
+
+
+

Sección Stats

+

+ Estadisticas + Personales +

+

+ Busca un jugador por nombre o ID y revisa sus resultados semanales y + mensuales con base en datos materializados. +

+

+ Comprobando disponibilidad del backend +

+ +
+
+
+ +
+
+
+
+

Buscador de jugadores

+

Buscar por nombre o ID

+
+

+ Usa minimo 1 carácter para comenzar la consulta. +

+
+ +
+ + +
+
+

+ Esperando consulta. +

+
+
+
+ + + +
+
+
+

Ranking anual

+

Top 20 anual - Proximo despliegue

+
+

+ Se reservará este bloque para integrar ranking anual snapshot cuando el endpoint esté activo. +

+
+

Top 20 anual (futuro)

+

La implementación real de ranking anual se conectara en una siguiente tarea.

+
+
+
+
+
+ + + + + +