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"); 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 annualMetricSupportedOnly = "kills"; const annualLimit = 20; const annualServerId = "all"; const messages = { backendChecking: "Comprobando disponibilidad del backend", backendOnline: "Backend operativo", backendUnavailable: "Backend no disponible. Reintenta en unos segundos.", backendUnavailableForSearch: "Backend no disponible. No se pueden buscar jugadores ni cargar su perfil.", backendUnavailableForAnnual: "Backend no disponible. No se puede consultar el ranking anual.", searchNoInput: "Escribe un nombre o ID para buscar.", searchLoading: "Buscando jugadores...", searchEmpty: "Sin resultados. Prueba con otro texto o ID.", searchError: "Error al buscar jugadores. Verifica backend y reintenta.", searchReadyPrefix: "Se encontraron ", searchReadySuffix: " jugador(es). Selecciona uno para ver sus estadisticas.", searchShortQueryHelp: "Usa al menos 1 caracter para iniciar la busqueda.", profileLoading: "Cargando estadisticas personales...", profileNoStats: "Jugador sin estadisticas suficientes para mostrar datos personales.", profileError: "No fue posible cargar las estadisticas del jugador.", annualLoading: "Cargando ranking anual...", annualMissing: "No hay snapshot generado para el ano solicitado. Mostrara estado missing hasta que exista.", annualReadyEmpty: "Snapshot anual listo pero sin datos para el ano y servidor seleccionado.", annualUnsupportedMetric: "La metrica anual solicitada no esta soportada en V1.", annualMetricInvalid: "Parametro de ranking anual invalido. Usa metric=kills en V1.", annualReadyPrefix: "Ranking anual listo para", weeklyPlaceholder: "Sin datos semanales. El ranking semanal se actualiza al cargar un jugador.", monthlyPlaceholder: "Sin datos mensuales. El ranking mensual se actualiza al cargar un jugador.", profileReadyTitle: "Perfil personal", profileEmptyTitle: "Selecciona un jugador para ver sus estadisticas.", weeklySummaryHelp: "Resumen semanal: muestra la ventana activa del jugador.", monthlySummaryHelp: "Resumen mensual: muestra la ventana activa del jugador.", }; let isBackendOnline = false; if (searchHelpNode) { searchHelpNode.textContent = "Usa el buscador para encontrar un jugador. Al seleccionar uno veras resumen semanal y mensual. " + "El ranking anual se consulta por separado con el ano indicado."; } setBackendState(messages.backendChecking, false); if (annualYearInput) { annualYearInput.value = String(annualDefaultYear); } clearProfilePanel(false); refreshBackendHealth(); if (annualForm && annualYearInput) { annualForm.addEventListener("submit", (event) => { event.preventDefault(); void loadAnnualRanking(); }); } if (searchForm && searchInput) { searchForm.addEventListener("submit", (event) => { event.preventDefault(); const query = (searchInput.value || "").trim(); if (!query) { setSearchState("error", messages.searchNoInput); 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(messages.backendUnavailable, false); if (searchStateNode) { searchStateNode.textContent = messages.backendUnavailableForSearch; searchStateNode.className = "stats-state stats-state--error"; } setAnnualState("error", messages.backendUnavailableForAnnual); } function setSearchState(state, message) { if (!searchStateNode) { return; } searchStateNode.textContent = message; 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; } profilePanel.hidden = false; profileStateNode.textContent = message; profileStateNode.className = `stats-state stats-state--${state}`; summaryGrid.innerHTML = ""; if (weeklySummaryNode) { weeklySummaryNode.textContent = "Cargando ..."; } if (monthlySummaryNode) { monthlySummaryNode.textContent = "Cargando ..."; } } 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") { throw new Error("Unexpected health payload"); } setBackendState(messages.backendOnline, true); setAnnualState( "neutral", "Backend disponible. Selecciona un ano para cargar el ranking anual (kills).", ); void loadAnnualRanking(); } catch (error) { markAsBackendUnavailable(); console.warn("Backend health check failed", error); } } async function searchPlayers(query) { if (!resultListNode) { return; } if (!query) { setSearchState("error", messages.searchShortQueryHelp); return; } if (!isBackendOnline) { markAsBackendUnavailable(); return; } setSearchState("loading", messages.searchLoading); clearProfilePanel(true); 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 busqueda invalida"); } const items = normalizeArray(payload.data?.items); if (!items.length) { setSearchState("empty", messages.searchEmpty); return; } setSearchState( "ready", `${messages.searchReadyPrefix}${items.length}${messages.searchReadySuffix}`, ); 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", messages.searchError); } } 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", messages.annualLoading); 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) { const annualErrorPayload = await safeParseJson(annualResponse); const annualErrorMessage = String( annualErrorPayload?.message || annualErrorPayload?.detail || "", ).toLowerCase(); if (isAnnualMetricUnsupportedError(annualResponse.status, annualErrorMessage)) { setAnnualState("warning", messages.annualUnsupportedMetric); return; } if (annualResponse.status === 400 && annualErrorMessage.includes("metric")) { setAnnualState("warning", messages.annualMetricInvalid); return; } 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 isAnnualMetricUnsupportedError(statusCode, message) { const normalized = String(message || "").toLowerCase(); return ( statusCode === 400 && normalized.includes("metric") && normalized.includes("unsupported") && annualMetric.toLowerCase() === annualMetricSupportedOnly ); } 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); const year = safeInt(data.year, annualDefaultYear); const metric = escapeHtml(String(data.metric || annualMetric)); if (snapshotStatus !== "ready" || !items.length) { const isReadyButEmpty = snapshotStatus === "ready" && !items.length; setAnnualState( "neutral", isReadyButEmpty ? messages.annualReadyEmpty : messages.annualMissing, ); annualContentNode.innerHTML = ""; return; } setAnnualState( "neutral", `${messages.annualReadyPrefix} ${serverId}, ano ${year}, metrica ${metric}`, ); annualContentNode.innerHTML = `

Top ${limit} anual

Servidor: ${escapeHtml(serverId)}

Metrica: ${escapeHtml(metric)}

Partidas fuente: ${safeInt(sourceMatches, 0)}

Actualizado: ${escapeHtml(generatedAt || "No disponible")}

${renderAnnualRows(items)}
`; } 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 `

#${rank} ${playerName} (ID: ${playerId})

Valor: ${metricValue} - Partidas: ${matches}

Kills: ${kills} - Deaths: ${deaths} - Teamkills: ${teamkills} - K/D: ${kd}

`; }) .join(""); } 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", messages.profileLoading); if (!profilePanel) { return; } 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 invalida"); } const data = payload.data || {}; profilePanel.hidden = false; profileTitle.textContent = messages.profileReadyTitle; 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})` : `${messages.profileNoStats} (${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(messages.backendOnline, true); isBackendOnline = true; } catch (error) { console.warn("Player profile failed", error); markAsBackendUnavailable(); if (profilePanel) { profilePanel.hidden = false; } profileTitle.textContent = messages.profileReadyTitle; profileStateNode.textContent = messages.profileError; profileStateNode.className = "stats-state stats-state--error"; summaryGrid.innerHTML = ""; if (weeklySummaryNode) { weeklySummaryNode.textContent = messages.weeklyPlaceholder; } if (monthlySummaryNode) { monthlySummaryNode.textContent = messages.monthlyPlaceholder; } } } function clearProfilePanel(openPanel) { if (!profilePanel || !summaryGrid || !profileStateNode) { return; } if (openPanel) { profilePanel.hidden = true; } summaryGrid.innerHTML = ""; if (weeklySummaryNode) { weeklySummaryNode.textContent = messages.weeklyPlaceholder; } if (monthlySummaryNode) { monthlySummaryNode.textContent = messages.monthlyPlaceholder; } profileStateNode.textContent = messages.profileEmptyTitle; profileStateNode.className = "stats-state stats-state--neutral"; } function formatRankingSummary(ranking, timeframeLabel) { if (!ranking || !Number.isFinite(safeParseNumber(ranking.ranking_position))) { return `Sin ranking ${timeframeLabel.toLowerCase()} registrado para este jugador.`; } const rank = safeInt(ranking.ranking_position, 0); const metric = escapeHtml(String(ranking.metric || annualMetric)); 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 `

Posicion: ${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 safeParseJson(response) { if (!response) { return null; } return response .json() .then((parsed) => parsed) .catch(() => null); } 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("'", "'"); } });