Add Stats frontend page

This commit is contained in:
devRaGonSa
2026-06-08 12:35:13 +02:00
parent e787dd134a
commit 49cb4778f7
6 changed files with 766 additions and 0 deletions

View File

@@ -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=<query>`
- `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=<query>`
- `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.

View File

@@ -916,6 +916,170 @@ h2 {
min-width: 180px; 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 { .server-action-link--disabled {
border-color: rgba(159, 168, 141, 0.18); border-color: rgba(159, 168, 141, 0.18);
background: linear-gradient(180deg, rgba(42, 46, 39, 0.5), rgba(19, 22, 17, 0.7)); 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; font-size: 0.98rem;
} }
.stats-search-form__fields {
grid-template-columns: 1fr;
}
.stats-search-form__fields .discord-button {
width: 100%;
}
.logo-frame { .logo-frame {
min-height: 180px; min-height: 180px;
padding: 16px 18px; padding: 16px 18px;

339
frontend/assets/js/stats.js Normal file
View File

@@ -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 `
<article class="stats-result-item">
<button class="stats-result-item__button" type="button" data-player-id="${playerId}">
<div class="stats-result-item__main">
<p class="stats-result-item__name">${playerName}</p>
<p class="stats-result-item__meta">ID: ${playerId}</p>
</div>
<p class="stats-result-item__metrics">
Partidas: ${matches} · Última aparición: ${lastSeen}
</p>
${serversSeen ? `<p class="stats-result-item__meta">Servidores: ${serversSeen}</p>` : ""}
</button>
</article>
`;
}
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 = `
<article class="stats-summary-card">
<p class="stats-summary-title">Identidad</p>
<p><strong>Jugador:</strong> ${escapeHtml(playerName)}</p>
<p><strong>ID:</strong> ${escapeHtml(playerIdText)}</p>
<p><strong>Partidas:</strong> ${safeInt(data.matches_considered, 0)}</p>
</article>
<article class="stats-summary-card">
<p class="stats-summary-title">Totales</p>
<p><strong>Kills:</strong> ${safeInt(data.kills, 0)}</p>
<p><strong>Deaths:</strong> ${safeInt(data.deaths, 0)}</p>
<p><strong>Teamkills:</strong> ${safeInt(data.teamkills, 0)}</p>
</article>
<article class="stats-summary-card">
<p class="stats-summary-title">Ratios</p>
<p><strong>K/D:</strong> ${safeDecimal(data.kd_ratio, 2, "0.00")}</p>
<p><strong>Kills/partida:</strong> ${safeDecimal(data.kills_per_match, 2, "0.00")}</p>
<p><strong>Deaths/partida:</strong> ${safeDecimal(data.deaths_per_match, 2, "0.00")}</p>
</article>
`;
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 `
<p><strong>Posición:</strong> ${rank}</p>
<p><strong>Metric:</strong> ${metric}</p>
<p>${windowRange}</p>
`;
}
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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
});

View File

@@ -20,6 +20,9 @@
<a class="secondary-button secondary-button--ghost" href="./index.html"> <a class="secondary-button secondary-button--ghost" href="./index.html">
VOLVER INICIO VOLVER INICIO
</a> </a>
<a class="secondary-button secondary-button--ghost" href="./stats.html">
STATS
</a>
<p class="eyebrow">Historico propio</p> <p class="eyebrow">Historico propio</p>
</div> </div>
<div class="historical-hero__layout historical-hero__layout--registry"> <div class="historical-hero__layout historical-hero__layout--registry">

View File

@@ -48,6 +48,9 @@
> >
Unirse al Discord Unirse al Discord
</a> </a>
<a class="secondary-button" href="./stats.html">
Stats
</a>
<a class="secondary-button" href="./historico.html"> <a class="secondary-button" href="./historico.html">
Ver historico propio Ver historico propio
</a> </a>

123
frontend/stats.html Normal file
View File

@@ -0,0 +1,123 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="Consulta estadísticas personales de jugadores en el panel Stats de HLL Vietnam."
/>
<title>Stats - HLL Vietnam</title>
<link rel="stylesheet" href="./assets/css/styles.css" />
</head>
<body data-backend-base-url="http://127.0.0.1:8000">
<div class="page-shell stats-page-shell">
<header class="hero stats-hero">
<div class="hero__overlay"></div>
<div class="hero__content">
<div class="hero__copy">
<p class="eyebrow eyebrow--section">Sección Stats</p>
<h1 class="hero__title">
Estadisticas
<span class="hero__title-accent">Personales</span>
</h1>
<p class="hero__text">
Busca un jugador por nombre o ID y revisa sus resultados semanales y
mensuales con base en datos materializados.
</p>
<p class="status-chip status-chip--fallback" id="stats-backend-state">
Comprobando disponibilidad del backend
</p>
<div class="hero__actions stats__hero-actions">
<a class="secondary-button secondary-button--ghost" href="./index.html">
Volver al inicio
</a>
<a class="secondary-button secondary-button--compact" href="./historico.html">
Historico
</a>
</div>
</div>
</div>
</header>
<main class="content stats-content">
<section class="panel">
<div class="panel__shell">
<div class="panel__header">
<p class="eyebrow eyebrow--section">Buscador de jugadores</p>
<h2>Buscar por nombre o ID</h2>
</div>
<p class="panel__intro panel__intro--tight" id="stats-search-help">
Usa minimo 1 carácter para comenzar la consulta.
</p>
<form class="stats-search-form" id="stats-search-form">
<label class="stats-search-form__label" for="stats-search-input">
Jugador
</label>
<div class="stats-search-form__fields">
<input
class="stats-search-input"
id="stats-search-input"
type="search"
name="query"
placeholder="Ej: Rambo o 76561198000000000"
autocomplete="off"
/>
<button class="discord-button" type="submit">
Buscar
</button>
</div>
</form>
<p class="stats-state stats-state--neutral" id="stats-search-state" role="status" aria-live="polite">
Esperando consulta.
</p>
<div class="stats-result-list" id="stats-result-list" aria-live="polite"></div>
</div>
</section>
<section class="panel stats-panel" id="stats-profile-panel" hidden>
<div class="panel__shell">
<div class="panel__header">
<p class="eyebrow eyebrow--section">Resumen de jugador</p>
<h2 id="stats-profile-title">Perfil personal</h2>
</div>
<p class="panel__intro panel__intro--tight" id="stats-profile-state">
Selecciona un jugador para ver sus estadisticas.
</p>
<div class="stats-summary-grid" id="stats-summary-grid"></div>
<div class="stats-ranking-grid">
<article class="stats-summary-card">
<p class="stats-summary-title">Resumen semanal</p>
<div id="stats-weekly-summary">Sin datos de semana disponibles.</div>
</article>
<article class="stats-summary-card">
<p class="stats-summary-title">Resumen mensual</p>
<div id="stats-monthly-summary">Sin datos de mes disponibles.</div>
</article>
</div>
</div>
</section>
<section class="panel">
<div class="panel__shell">
<div class="panel__header">
<p class="eyebrow eyebrow--section">Ranking anual</p>
<h2>Top 20 anual - Proximo despliegue</h2>
</div>
<p class="panel__intro panel__intro--tight">
Se reservará este bloque para integrar ranking anual snapshot cuando el endpoint esté activo.
</p>
<article class="stats-summary-card stats-summary-card--placeholder">
<p>Top 20 anual (futuro)</p>
<p>La implementación real de ranking anual se conectara en una siguiente tarea.</p>
</article>
</div>
</section>
</main>
</div>
<script src="./assets/js/config.js"></script>
<script src="./assets/js/stats.js"></script>
</body>
</html>