Top ${limit} anual
${renderAnnualRows(items)}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
| Posici\u00f3n | Jugador | Valor / Kills | Partidas | Muertes | Teamkills | K/D |
|---|
${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)}
Ventana mensual
${escapeHtml(messages.monthlyWindowUnavailable)}
Ventana mensual
Kills: ${safeInt(monthlyData?.kills, 0)}
Deaths: ${safeInt(monthlyData?.deaths, 0)}
Teamkills: ${safeInt(monthlyData?.teamkills, 0)}
Identidad
Jugador: ${escapeHtml(playerName)}
ID: ${escapeHtml(playerIdText)}
Partidas semanales: ${safeInt(weeklyData?.matches_considered, 0)}
Partidas mensuales: ${safeInt(monthlyData?.matches_considered, 0)}
Comparativa ${label.toLowerCase()}
Kills por partida: ${killsPerMatch}
${escapeHtml(rankingState.detail)}
Lectura comparada
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.
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("'", "'"); } });