Connect annual ranking snapshot to Stats frontend
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
---
|
||||
id: TASK-170
|
||||
title: Connect annual ranking snapshot to Stats frontend
|
||||
status: done
|
||||
type: frontend
|
||||
team: Frontend Senior
|
||||
supporting_teams: []
|
||||
roadmap_item: foundation
|
||||
priority: medium
|
||||
---
|
||||
|
||||
# TASK-170 - Connect annual ranking snapshot to Stats frontend
|
||||
|
||||
## Goal
|
||||
|
||||
Conectar el bloque anual top 20 del módulo Stats con la API pública ya creada:
|
||||
|
||||
- `GET /api/stats/rankings/annual?year=<year>&server_id=<server-or-all>&metric=kills&limit=20`
|
||||
|
||||
## Context
|
||||
|
||||
Después de implementar el endpoint anual en backend y diseñar la integración frontend de Stats, esta tarea conecta el bloque de placeholder con datos reales de snapshots precomputados y mantiene el comportamiento por estados sin romper la sección actual.
|
||||
|
||||
No se implementan cambios de backend ni migraciones en esta tarea.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Revisar los archivos obligatorios antes de iniciar cambios:
|
||||
- `AGENTS.md`
|
||||
- `ai/repo-context.md`
|
||||
- `ai/architecture-index.md`
|
||||
- `docs/stats-section-functional-plan.md`
|
||||
- `docs/stats-frontend-integration-plan.md`
|
||||
- `docs/annual-ranking-snapshot-schema-plan.md`
|
||||
- `frontend/stats.html`
|
||||
- `frontend/assets/js/stats.js`
|
||||
- `frontend/assets/css/styles.css`
|
||||
- `backend/app/routes.py`
|
||||
- `backend/app/payloads.py`
|
||||
- `backend/app/rcon_annual_rankings.py`
|
||||
2. Mantener el placeholder de ranking anual en el HTML, reemplazándolo por estados y tabla dinámica del endpoint.
|
||||
3. Implementar consumo del endpoint anual con estado por:
|
||||
- ready
|
||||
- missing
|
||||
- error/backend no disponible
|
||||
4. Mantener `metric=kills` y `limit=20` fijos en V1.
|
||||
5. Validar carga/estado con `node --check` y pruebas de endpoint si hay backend.
|
||||
|
||||
## 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`
|
||||
- `docs/annual-ranking-snapshot-schema-plan.md`
|
||||
- `frontend/stats.html`
|
||||
- `frontend/assets/js/stats.js`
|
||||
- `frontend/assets/css/styles.css`
|
||||
- `backend/app/routes.py`
|
||||
- `backend/app/payloads.py`
|
||||
- `backend/app/rcon_annual_rankings.py`
|
||||
|
||||
## Expected Files to Modify
|
||||
|
||||
- `frontend/stats.html`
|
||||
- `frontend/assets/js/stats.js`
|
||||
- `frontend/assets/css/styles.css` (solo si se requiere ajuste visual para el bloque anual)
|
||||
- `ai/tasks/pending/TASK-170-connect-annual-ranking-snapshot-to-stats-frontend.md` (estado + outcome)
|
||||
|
||||
## Constraints
|
||||
|
||||
- Mantener frontend vanilla sin frameworks ni cambios de backend.
|
||||
- No crear migraciones.
|
||||
- No modificar `frontend/assets/js/partida-actual.js`.
|
||||
- No modificar `frontend/assets/img/clans/bxb.png`.
|
||||
- No reintroducir Comunidad Hispana #03.
|
||||
- Mantener el bloque anual como consumo de snapshot (`ready`, `missing` o backend no disponible).
|
||||
- No implementar generador anual desde frontend.
|
||||
|
||||
## Validation
|
||||
|
||||
Antes de cerrar:
|
||||
|
||||
- `node --check frontend/assets/js/stats.js`
|
||||
- (si aplica) `python -m http.server` y comprobar HTTP 200 para `frontend/stats.html` y `frontend/assets/js/stats.js`.
|
||||
- Consumir `GET /api/stats/rankings/annual` desde la UI cuando backend local esté disponible.
|
||||
- Verificar caso con snapshot existente y caso sin snapshot (`snapshot_status="missing"`).
|
||||
- `scripts/run-integration-tests.ps1` si aplica.
|
||||
- `git diff --name-only` y validar que el alcance no excede la task.
|
||||
- `git status --short --untracked-files=all` y confirmar que no se tocó `partida-actual.js` ni `img/clans/bxb.png`.
|
||||
|
||||
## Outcome
|
||||
|
||||
- Archivos modificados:
|
||||
- `frontend/stats.html`
|
||||
- `frontend/assets/js/stats.js`
|
||||
- `frontend/assets/css/styles.css` (ajustes del bloque anual)
|
||||
- `ai/tasks/done/TASK-170-connect-annual-ranking-snapshot-to-stats-frontend.md`
|
||||
- Endpoint consumido:
|
||||
- `GET /api/stats/rankings/annual?year=<year>&server_id=<server-or-all>&metric=kills&limit=20`
|
||||
- Comportamiento `snapshot_status=ready`:
|
||||
- Muestra el bloque de ranking anual con filas del top (`ranking_position`, `player_name`, `metric_value`, `matches_considered`, `kills`, `deaths`, `teamkills`, `kd_ratio`), metadatos de servidor, fuente y fecha.
|
||||
- Comportamiento `snapshot_status=missing`:
|
||||
- Muestra estado informativo de no disponibilidad de ranking para el año seleccionado.
|
||||
- No lanza error ni bloquea el resto del flujo.
|
||||
- Comportamiento con backend no disponible:
|
||||
- El estado general y anual muestran error (`Backend no disponible...`) y no se hacen cambios de UI inconsistentes.
|
||||
- Validaciones ejecutadas:
|
||||
- `node --check frontend/assets/js/stats.js`
|
||||
- `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1`
|
||||
- Servido local con `python -m http.server` y comprobación HTTP 200 de `frontend/stats.html` y `frontend/assets/js/stats.js`
|
||||
- `git status --short --untracked-files=all`
|
||||
- `git diff --name-only`
|
||||
- Limitaciones conocidas:
|
||||
- No se pudo validar integración real contra backend porque `http://127.0.0.1:8000` no estaba disponible en este entorno.
|
||||
- Los textos en este bloque se mantienen en ASCII para evitar cambios de encoding.
|
||||
- Siguiente tarea recomendada:
|
||||
- Conectar la vista de `Stats` con `frontend/index.html`/`frontend/historico.html` si aplica y/o añadir mejoras visuales en el bloque anual.
|
||||
|
||||
|
||||
## Change Budget
|
||||
|
||||
- Preferir cambios < 5 archivos y < 200 líneas.
|
||||
- Mantener cambio pequeño y verificable.
|
||||
@@ -1034,6 +1034,34 @@ h2 {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.stats-annual-table-wrap {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.stats-annual-meta {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.stats-annual-item {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid rgba(159, 168, 141, 0.22);
|
||||
border-radius: 14px;
|
||||
margin-bottom: 8px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(28, 34, 25, 0.82), rgba(12, 15, 11, 0.92));
|
||||
}
|
||||
|
||||
.stats-annual-item p {
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
|
||||
.stats-annual-sub {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.stats-summary-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
|
||||
@@ -12,11 +12,28 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
const summaryGrid = document.getElementById("stats-summary-grid");
|
||||
const weeklySummaryNode = document.getElementById("stats-weekly-summary");
|
||||
const monthlySummaryNode = document.getElementById("stats-monthly-summary");
|
||||
const annualForm = document.getElementById("stats-annual-form");
|
||||
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 annualLimit = 20;
|
||||
const annualServerId = "all";
|
||||
|
||||
let isBackendOnline = false;
|
||||
|
||||
setBackendState("Comprobando disponibilidad del backend", false);
|
||||
if (annualYearInput) {
|
||||
annualYearInput.value = String(annualDefaultYear);
|
||||
}
|
||||
refreshBackendHealth();
|
||||
if (annualForm && annualYearInput) {
|
||||
annualForm.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
void loadAnnualRanking();
|
||||
});
|
||||
}
|
||||
|
||||
if (searchForm && searchInput) {
|
||||
searchForm.addEventListener("submit", (event) => {
|
||||
@@ -46,6 +63,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
searchStateNode.textContent = "Backend no disponible. Reintenta en unos segundos.";
|
||||
searchStateNode.className = "stats-state stats-state--error";
|
||||
}
|
||||
setAnnualState("error", "Backend no disponible. No se puede cargar ranking anual.");
|
||||
}
|
||||
|
||||
async function refreshBackendHealth() {
|
||||
@@ -57,6 +75,8 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
const payload = await response.json();
|
||||
if (payload && payload.status === "ok") {
|
||||
setBackendState("Backend operativo", true);
|
||||
setAnnualState("loading", "Cargando ranking anual...");
|
||||
void loadAnnualRanking();
|
||||
return;
|
||||
}
|
||||
throw new Error("Unexpected health payload");
|
||||
@@ -116,6 +136,126 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAnnualRanking() {
|
||||
if (!annualForm || !annualYearInput || !annualContentNode) {
|
||||
return;
|
||||
}
|
||||
if (!isBackendOnline) {
|
||||
markAsBackendUnavailable();
|
||||
return;
|
||||
}
|
||||
|
||||
const year = resolveAnnualYear();
|
||||
if (year === null) {
|
||||
setAnnualState("error", "El ano ingresado no es valido.");
|
||||
return;
|
||||
}
|
||||
|
||||
setAnnualState("loading", "Cargando ranking anual...");
|
||||
annualContentNode.innerHTML = "";
|
||||
|
||||
try {
|
||||
const annualUrl =
|
||||
`${backendBaseUrl}/api/stats/rankings/annual?` +
|
||||
`year=${encodeURIComponent(year)}&` +
|
||||
`server_id=${encodeURIComponent(annualServerId)}&` +
|
||||
`metric=${encodeURIComponent(annualMetric)}&` +
|
||||
`limit=${encodeURIComponent(annualLimit)}`;
|
||||
|
||||
const annualResponse = await fetch(annualUrl);
|
||||
if (!annualResponse.ok) {
|
||||
throw new Error(`Annual ranking request failed with ${annualResponse.status}`);
|
||||
}
|
||||
|
||||
const payload = await annualResponse.json();
|
||||
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);
|
||||
markAsBackendUnavailable();
|
||||
setAnnualState("error", "Error al cargar ranking anual.");
|
||||
annualContentNode.innerHTML = "";
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAnnualYear() {
|
||||
const normalized = Number.parseInt(String(annualYearInput?.value || "").trim(), 10);
|
||||
if (!Number.isFinite(normalized) || normalized <= 0) {
|
||||
return null;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function renderAnnualRanking(data) {
|
||||
if (!annualContentNode || !annualStateNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const snapshotStatus = String(data.snapshot_status || "").toLowerCase();
|
||||
const items = normalizeArray(data.items);
|
||||
const limit = safeInt(data.limit, 0);
|
||||
const serverId = String(data.server_id || annualServerId);
|
||||
const sourceMatches = safeInt(data.source_matches_count, 0);
|
||||
const generatedAt = formatDateTime(data.generated_at);
|
||||
|
||||
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.",
|
||||
);
|
||||
if (!items.length) {
|
||||
annualContentNode.innerHTML = "";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setAnnualState(
|
||||
"neutral",
|
||||
`Ranking anual listo para ${serverId}. Generado: ${generatedAt || "sin marca de tiempo"}.`,
|
||||
);
|
||||
|
||||
annualContentNode.innerHTML = `
|
||||
<article class="stats-summary-card">
|
||||
<p class="stats-summary-title">Top ${limit} anual</p>
|
||||
<div class="stats-annual-meta">
|
||||
<p><strong>Servidor:</strong> ${escapeHtml(serverId)}</p>
|
||||
<p><strong>Metrica:</strong> kills</p>
|
||||
<p><strong>Partidas fuente:</strong> ${safeInt(sourceMatches, 0)}</p>
|
||||
<p><strong>Actualizado:</strong> ${escapeHtml(generatedAt || "No disponible")}</p>
|
||||
</div>
|
||||
${renderAnnualRows(items)}
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderAnnualRows(items) {
|
||||
return items
|
||||
.map((item) => {
|
||||
const rank = safeInt(item.ranking_position, 0);
|
||||
const playerId = escapeHtml(String(item.player_id || ""));
|
||||
const playerName = escapeHtml(String(item.player_name || "Jugador sin nombre"));
|
||||
const metricValue = safeInt(item.metric_value, 0);
|
||||
const matches = safeInt(item.matches_considered, 0);
|
||||
const kills = safeInt(item.kills, 0);
|
||||
const deaths = safeInt(item.deaths, 0);
|
||||
const teamkills = safeInt(item.teamkills, 0);
|
||||
const kd = safeDecimal(item.kd_ratio, 2, "0.00");
|
||||
return `
|
||||
<article class="stats-annual-item">
|
||||
<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>Kills:</strong> ${kills} · <strong>Deaths:</strong> ${deaths} · <strong>Teamkills:</strong> ${teamkills} · <strong>K/D:</strong> ${kd}</p>
|
||||
</article>
|
||||
`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function renderResultItem(item) {
|
||||
const playerId = escapeHtml(String(item.player_id || ""));
|
||||
const playerName = escapeHtml(String(item.player_name || "Jugador sin nombre"));
|
||||
@@ -235,6 +375,14 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
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;
|
||||
@@ -336,4 +484,3 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<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."
|
||||
content="Consulta estadisticas personales de jugadores en el panel Stats de HLL Vietnam."
|
||||
/>
|
||||
<title>Stats - HLL Vietnam</title>
|
||||
<link rel="stylesheet" href="./assets/css/styles.css" />
|
||||
@@ -16,7 +16,7 @@
|
||||
<div class="hero__overlay"></div>
|
||||
<div class="hero__content">
|
||||
<div class="hero__copy">
|
||||
<p class="eyebrow eyebrow--section">Sección Stats</p>
|
||||
<p class="eyebrow eyebrow--section">Seccion Stats</p>
|
||||
<h1 class="hero__title">
|
||||
Estadisticas
|
||||
<span class="hero__title-accent">Personales</span>
|
||||
@@ -48,7 +48,7 @@
|
||||
<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.
|
||||
Usa minimo 1 caracter para comenzar la consulta.
|
||||
</p>
|
||||
<form class="stats-search-form" id="stats-search-form">
|
||||
<label class="stats-search-form__label" for="stats-search-input">
|
||||
@@ -102,15 +102,31 @@
|
||||
<div class="panel__shell">
|
||||
<div class="panel__header">
|
||||
<p class="eyebrow eyebrow--section">Ranking anual</p>
|
||||
<h2>Top 20 anual - Proximo despliegue</h2>
|
||||
<h2>Top 20 anual</h2>
|
||||
</div>
|
||||
<p class="panel__intro panel__intro--tight">
|
||||
Se reservará este bloque para integrar ranking anual snapshot cuando el endpoint esté activo.
|
||||
El ranking anual consume snapshots precomputados de backend para evitar recalculo en cada vista.
|
||||
</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>
|
||||
<form class="stats-annual-form" id="stats-annual-form">
|
||||
<label class="stats-search-form__label" for="stats-annual-year">Ano</label>
|
||||
<div class="stats-search-form__fields">
|
||||
<input
|
||||
class="stats-search-input"
|
||||
id="stats-annual-year"
|
||||
type="number"
|
||||
min="2019"
|
||||
value=""
|
||||
aria-describedby="stats-annual-state"
|
||||
/>
|
||||
<button class="discord-button" type="submit">
|
||||
Cargar ranking
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<p class="stats-state stats-state--neutral" id="stats-annual-state" role="status" aria-live="polite">
|
||||
Esperando ano para cargar ranking anual.
|
||||
</p>
|
||||
<div class="stats-annual-table-wrap" id="stats-annual-content" aria-live="polite"></div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
Reference in New Issue
Block a user