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 profilePanel = document.getElementById("stats-profile-panel"); const profileTitle = document.getElementById("stats-profile-title"); const profileStateNode = document.getElementById("stats-profile-state"); const comparisonGrid = document.getElementById("stats-comparison-grid"); 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 = { 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 estad\u00edsticas.", searchShortQueryHelp: "Usa al menos 1 car\u00e1cter para iniciar la b\u00fasqueda.", profileLoading: "Cargando estad\u00edsticas personales...", profileNoStats: "Jugador sin estad\u00edsticas suficientes para mostrar datos personales.", profileError: "No fue posible cargar las estad\u00edsticas del jugador.", annualLoading: "Cargando ranking anual...", annualMissing: "Todavia no hay ranking anual publicado para el a\u00f1o solicitado.", annualReadyEmpty: "El ranking anual ya esta disponible, pero no muestra filas para ese filtro.", annualUnsupportedMetric: "La m\u00e9trica anual solicitada no est\u00e1 soportada en V1.", annualMetricInvalid: "Par\u00e1metro de ranking anual inv\u00e1lido. Usa metric=kills en V1.", annualReadyPrefix: "Ranking anual disponible 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.", weeklyWindowUnavailable: "No se pudo cargar el bloque semanal; se muestran los datos mensuales.", monthlyWindowUnavailable: "No se pudo cargar el bloque mensual; se muestran los datos semanales.", weeklyWindowUnavailableSummary: "Resumen semanal no disponible temporalmente.", monthlyWindowUnavailableSummary: "Resumen mensual no disponible temporalmente.", profileReadyTitle: "Perfil personal", profileEmptyTitle: "Selecciona un jugador para ver sus estad\u00edsticas.", partialProfileLoadWarning: "Algunos bloques de ranking no se cargaron; se mantienen los disponibles.", }; let isBackendOnline = false; if (searchHelpNode) { searchHelpNode.textContent = "Usa el buscador para encontrar un jugador. Al seleccionar uno ver\u00e1s resumen semanal y mensual. " + "El ranking anual se consulta por separado con el a\u00f1o indicado."; } 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 markAsBackendUnavailable() { isBackendOnline = 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}`; if (comparisonGrid) { comparisonGrid.innerHTML = ""; } 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"); } isBackendOnline = true; setAnnualState( "neutral", "Backend disponible. Selecciona un a\u00f1o para cargar el ranking anual.", ); 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 a\u00f1o ingresado no es v\u00e1lido."); 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}, a\u00f1o ${year}.`, ); annualContentNode.innerHTML = `

Top ${limit} anual

Servidor

${escapeHtml(serverId)}

A\u00f1o

${year}

Lectura

${escapeHtml(metric)}

Partidas base

${safeInt(sourceMatches, 0)}

Actualizado

${escapeHtml(generatedAt || "No disponible")}
${renderAnnualRows(items)}
`; } function renderAnnualRows(items) { const rowsMarkup = 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 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}
${metricValue} ${matches} ${deaths} ${teamkills} ${kd} `; }) .join(""); return `
${rowsMarkup}
Posici\u00f3n Jugador Valor / Kills Partidas Muertes Teamkills K/D
`; } 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 [weeklyProfileResult, monthlyProfileResult] = await Promise.allSettled([ fetchPlayerProfile(playerId, "weekly"), fetchPlayerProfile(playerId, "monthly"), ]); const weeklyData = weeklyProfileResult.status === "fulfilled" ? weeklyProfileResult.value : null; const monthlyData = monthlyProfileResult.status === "fulfilled" ? monthlyProfileResult.value : null; const weeklyFailed = weeklyProfileResult.status === "rejected"; const monthlyFailed = monthlyProfileResult.status === "rejected"; if (!weeklyData && !monthlyData) { throw new Error("Both weekly and monthly profile windows failed."); } profilePanel.hidden = false; profileTitle.textContent = messages.profileReadyTitle; const playerName = String( (weeklyData?.player_name || monthlyData?.player_name || "Sin nombre"), ); const playerIdText = String(weeklyData?.player_id || monthlyData?.player_id || playerId); const hasWeeklyStats = Number(weeklyData?.matches_considered || 0) > 0; const hasMonthlyStats = Number(monthlyData?.matches_considered || 0) > 0; const hasStats = hasWeeklyStats || hasMonthlyStats; const partialLoadWarning = weeklyFailed || monthlyFailed; profileStateNode.textContent = hasStats ? `${playerName} (${playerIdText})` : `${messages.profileNoStats} (${playerIdText})`; profileStateNode.className = hasStats ? "stats-state stats-state--neutral" : "stats-state stats-state--warning"; summaryGrid.innerHTML = renderProfileSummaryCards({ playerName, playerIdText, weeklyData, monthlyData, weeklyFailed, monthlyFailed, }); if (partialLoadWarning) { profileStateNode.textContent += ` ${messages.partialProfileLoadWarning}`; } if (comparisonGrid) { comparisonGrid.innerHTML = renderComparisonCards({ weeklyData, weeklyFailed, monthlyData, monthlyFailed, }); } if (weeklySummaryNode) { weeklySummaryNode.innerHTML = weeklyFailed ? `

${escapeHtml(messages.weeklyWindowUnavailableSummary)}

` : formatRankingSummary( weeklyData?.weekly_ranking, "Semanal", weeklyData?.matches_considered, ); } if (monthlySummaryNode) { monthlySummaryNode.innerHTML = monthlyFailed ? `

${escapeHtml(messages.monthlyWindowUnavailableSummary)}

` : formatRankingSummary( monthlyData?.monthly_ranking, "Mensual", monthlyData?.matches_considered, ); } 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"; if (comparisonGrid) { comparisonGrid.innerHTML = ""; } summaryGrid.innerHTML = ""; if (weeklySummaryNode) { weeklySummaryNode.textContent = messages.weeklyPlaceholder; } if (monthlySummaryNode) { monthlySummaryNode.textContent = messages.monthlyPlaceholder; } } } function renderProfileSummaryCards({ playerName, playerIdText, weeklyData, monthlyData, weeklyFailed, monthlyFailed, }) { const weeklySummary = weeklyFailed ? `

Ventana semanal

${escapeHtml(messages.weeklyWindowUnavailable)}

` : `

Ventana semanal

Kills: ${safeInt(weeklyData?.kills, 0)}

Deaths: ${safeInt(weeklyData?.deaths, 0)}

Teamkills: ${safeInt(weeklyData?.teamkills, 0)}

`; const monthlySummary = monthlyFailed ? `

Ventana mensual

${escapeHtml(messages.monthlyWindowUnavailable)}

` : `

Ventana mensual

Kills: ${safeInt(monthlyData?.kills, 0)}

Deaths: ${safeInt(monthlyData?.deaths, 0)}

Teamkills: ${safeInt(monthlyData?.teamkills, 0)}

`; return `

Identidad

Jugador: ${escapeHtml(playerName)}

ID: ${escapeHtml(playerIdText)}

Partidas semanales: ${safeInt(weeklyData?.matches_considered, 0)}

Partidas mensuales: ${safeInt(monthlyData?.matches_considered, 0)}

${weeklySummary} ${monthlySummary} `; } function clearProfilePanel(openPanel) { if (!profilePanel || !summaryGrid || !profileStateNode) { return; } if (openPanel) { profilePanel.hidden = true; } if (comparisonGrid) { comparisonGrid.innerHTML = ""; } 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"; } async function fetchPlayerProfile(playerId, timeframe) { const response = await fetch( `${backendBaseUrl}/api/stats/players/${encodeURIComponent(playerId)}?timeframe=${encodeURIComponent(timeframe)}`, ); 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"); } return payload.data || {}; } function renderComparisonCards({ weeklyData, weeklyFailed, monthlyData, monthlyFailed, }) { const cards = [ renderWindowComparisonCard( "Semanal", weeklyData, weeklyData?.weekly_ranking, weeklyFailed, ), renderWindowComparisonCard( "Mensual", monthlyData, monthlyData?.monthly_ranking, monthlyFailed, ), ]; if (!weeklyFailed && !monthlyFailed) { cards.push(renderDeltaComparisonCard(weeklyData, monthlyData)); } return cards.join(""); } function renderWindowComparisonCard(label, data, ranking, failed) { const rankingState = describeRankingState(ranking, data?.matches_considered, label, failed); const matches = safeInt(data?.matches_considered, 0); const kills = safeInt(data?.kills, 0); const deaths = safeInt(data?.deaths, 0); const kdRatio = safeDecimal(data?.kd_ratio, 2, "0.00"); const killsPerMatch = safeDecimal(data?.kills_per_match, 2, "0.00"); return `

Comparativa ${label.toLowerCase()}

Ventana ${label.toLowerCase()}

${escapeHtml(rankingState.title)}
Partidas ${matches}
Kills ${kills}
Deaths ${deaths}
K/D ${kdRatio}

Kills por partida: ${killsPerMatch}

${escapeHtml(rankingState.detail)}

`; } function renderDeltaComparisonCard(weeklyData, monthlyData) { const weeklyKpm = safeParseNumber(weeklyData?.kills_per_match); const monthlyKpm = safeParseNumber(monthlyData?.kills_per_match); const weeklyKd = safeParseNumber(weeklyData?.kd_ratio); const monthlyKd = safeParseNumber(monthlyData?.kd_ratio); const killsDelta = safeInt(monthlyData?.kills, 0) - safeInt(weeklyData?.kills, 0); const matchesDelta = safeInt(monthlyData?.matches_considered, 0) - safeInt(weeklyData?.matches_considered, 0); return `

Lectura comparada

Pulso semanal vs mensual

${escapeHtml(buildDeltaHeadline(killsDelta, matchesDelta))}
KPM semanal ${safeDecimal(weeklyKpm, 2, "0.00")}
KPM mensual ${safeDecimal(monthlyKpm, 2, "0.00")}
K/D semanal ${safeDecimal(weeklyKd, 2, "0.00")}
K/D mensual ${safeDecimal(monthlyKd, 2, "0.00")}

Delta de kills: ${formatSignedNumber(killsDelta)}

Delta de partidas: ${formatSignedNumber(matchesDelta)}

La lectura compara solo datos existentes del backend actual, sin recalcular rankings ni pedir nuevos endpoints.

`; } function formatRankingSummary(ranking, timeframeLabel, matchesConsidered) { const rankingState = describeRankingState(ranking, matchesConsidered, timeframeLabel); if (!ranking) { return escapeHtml(rankingState.detail); } if (!Number.isFinite(safeParseNumber(ranking.ranking_position))) { return `

Estado: ${escapeHtml(rankingState.title)}

${escapeHtml(rankingState.detail)}

${escapeHtml(formatWindowRange(ranking))}

`; } const rank = safeInt(ranking.ranking_position, 0); const metric = escapeHtml(String(ranking.metric || annualMetric)); return `

Posici\u00f3n: ${rank}

M\u00e9trica: ${metric}

${escapeHtml(formatWindowRange(ranking))}

`; } function describeRankingState(ranking, matchesConsidered, timeframeLabel, requestFailed = false) { const matches = safeInt(matchesConsidered, 0); const label = String(timeframeLabel || "ranking").toLowerCase(); if (requestFailed) { return { tone: "warning", title: "Bloque no disponible", detail: `No fue posible cargar los datos de la ventana ${label} en este intento.`, }; } if (!ranking) { return { tone: "error", title: "Ranking ausente", detail: `No se recibio bloque de ranking ${label} desde el backend.`, }; } if (Number.isFinite(safeParseNumber(ranking.ranking_position))) { return { tone: "ok", title: `Posici\u00f3n #${safeInt(ranking.ranking_position, 0)}`, detail: `Jugador posicionado en el ranking ${label} activo por kills.`, }; } if (matches <= 0) { return { tone: "warning", title: "Sin actividad", detail: `No hay actividad cerrada del jugador en la ventana ${label} actual.`, }; } const windowKind = String(ranking.window_kind || "").toLowerCase(); if (windowKind.startsWith("previous-")) { return { tone: "warning", title: "Profundidad insuficiente", detail: `La ventana ${label} activa usa fallback previo y el jugador no aparece posicionado.`, }; } return { tone: "warning", title: "Fuera del ranking visible", detail: `El jugador tiene actividad en la ventana ${label}, pero no figura en el ranking visible actual.`, }; } function buildDeltaHeadline(killsDelta, matchesDelta) { if (killsDelta === 0 && matchesDelta === 0) { return "Ritmo estable"; } if (killsDelta > 0 || matchesDelta > 0) { return "Presion mensual superior"; } return "Ventana corta mas intensa"; } function formatSignedNumber(value) { const parsed = safeInt(value, 0); if (parsed > 0) { return `+${parsed}`; } return String(parsed); } function formatWindowRange(ranking) { const windowLabel = String(ranking?.window_kind || "").trim(); const windowStart = formatDateTime(ranking?.window_start); const windowEnd = formatDateTime(ranking?.window_end); if (windowLabel) { return `${windowLabel}: ${windowStart} - ${windowEnd}`; } return `Ventana: ${windowStart} - ${windowEnd}`; } 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("'", "'"); } });