Add Stats frontend page
This commit is contained in:
126
ai/tasks/done/TASK-165-add-stats-frontend-page.md
Normal file
126
ai/tasks/done/TASK-165-add-stats-frontend-page.md
Normal 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.
|
||||
@@ -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;
|
||||
|
||||
339
frontend/assets/js/stats.js
Normal file
339
frontend/assets/js/stats.js
Normal 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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
<a class="secondary-button secondary-button--ghost" href="./index.html">
|
||||
VOLVER INICIO
|
||||
</a>
|
||||
<a class="secondary-button secondary-button--ghost" href="./stats.html">
|
||||
STATS
|
||||
</a>
|
||||
<p class="eyebrow">Historico propio</p>
|
||||
</div>
|
||||
<div class="historical-hero__layout historical-hero__layout--registry">
|
||||
|
||||
@@ -48,6 +48,9 @@
|
||||
>
|
||||
Unirse al Discord
|
||||
</a>
|
||||
<a class="secondary-button" href="./stats.html">
|
||||
Stats
|
||||
</a>
|
||||
<a class="secondary-button" href="./historico.html">
|
||||
Ver historico propio
|
||||
</a>
|
||||
|
||||
123
frontend/stats.html
Normal file
123
frontend/stats.html
Normal 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>
|
||||
|
||||
Reference in New Issue
Block a user