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

@@ -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
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">
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">

View File

@@ -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
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>