1940 lines
61 KiB
JavaScript
1940 lines
61 KiB
JavaScript
const HISTORICAL_SERVERS = Object.freeze([
|
|
{
|
|
slug: "comunidad-hispana-01",
|
|
label: "Comunidad Hispana #01",
|
|
},
|
|
{
|
|
slug: "comunidad-hispana-02",
|
|
label: "Comunidad Hispana #02",
|
|
},
|
|
{
|
|
slug: "all-servers",
|
|
label: "Todos",
|
|
},
|
|
]);
|
|
const HISTORICAL_SERVER_SLUGS = Object.freeze(
|
|
HISTORICAL_SERVERS.map((server) => server.slug),
|
|
);
|
|
const DEFAULT_HISTORICAL_SERVER = "all-servers";
|
|
const SNAPSHOT_CACHE_TTL_MS = 120000;
|
|
const STALE_SNAPSHOT_CACHE_TTL_MS = 30000;
|
|
const NEGATIVE_SNAPSHOT_CACHE_TTL_MS = 15000;
|
|
const RECENT_MATCHES_LIMIT = 100;
|
|
const DEFAULT_RECENT_MATCHES_PAGE_SIZE = 10;
|
|
const RECENT_MATCHES_PAGE_SIZES = Object.freeze([10, 25, 50, 100]);
|
|
let activeServerSlug = DEFAULT_HISTORICAL_SERVER;
|
|
let activeLeaderboardMetric;
|
|
let activeLeaderboardTimeframe;
|
|
let activeServerRequestId = 0;
|
|
let activeLeaderboardRequestId = 0;
|
|
let recentMatchesPagination;
|
|
const LEADERBOARD_TIMEFRAMES = Object.freeze([
|
|
{
|
|
key: "weekly",
|
|
label: "Semanal",
|
|
shortLabel: "semanal",
|
|
},
|
|
{
|
|
key: "monthly",
|
|
label: "Mensual",
|
|
shortLabel: "mensual",
|
|
},
|
|
]);
|
|
const LEADERBOARD_METRICS = Object.freeze([
|
|
{
|
|
key: "kills",
|
|
title: "Top kills",
|
|
valueHeading: "Kills",
|
|
emptyMessage: "Sin datos historicos suficientes para mostrar este ranking de kills.",
|
|
},
|
|
{
|
|
key: "deaths",
|
|
title: "Top muertes",
|
|
valueHeading: "Muertes",
|
|
emptyMessage: "Sin datos historicos suficientes para mostrar este ranking de muertes.",
|
|
},
|
|
{
|
|
key: "matches_over_100_kills",
|
|
title: "Top partidas con 100+ kills",
|
|
valueHeading: "Partidas 100+",
|
|
emptyMessage: "Ningun jugador ha registrado partidas de 100+ kills en esta ventana.",
|
|
},
|
|
{
|
|
key: "support",
|
|
title: "Top puntos de soporte",
|
|
valueHeading: "Soporte",
|
|
emptyMessage: "Sin datos historicos suficientes para mostrar este ranking de soporte.",
|
|
},
|
|
]);
|
|
const DEFAULT_LEADERBOARD_METRIC = LEADERBOARD_METRICS[0].key;
|
|
const DEFAULT_LEADERBOARD_TIMEFRAME = LEADERBOARD_TIMEFRAMES[0].key;
|
|
activeLeaderboardMetric = DEFAULT_LEADERBOARD_METRIC;
|
|
activeLeaderboardTimeframe = DEFAULT_LEADERBOARD_TIMEFRAME;
|
|
|
|
document.addEventListener("DOMContentLoaded", () => {
|
|
const backendBaseUrl =
|
|
document.body.dataset.backendBaseUrl || "http://127.0.0.1:8000";
|
|
const selectorButtons = Array.from(
|
|
document.querySelectorAll("[data-server-slug]"),
|
|
);
|
|
const leaderboardTimeframeButtons = Array.from(
|
|
document.querySelectorAll("[data-leaderboard-timeframe]"),
|
|
);
|
|
const leaderboardTabButtons = Array.from(
|
|
document.querySelectorAll("[data-leaderboard-metric]"),
|
|
);
|
|
const summaryNode = document.getElementById("historical-summary");
|
|
const rangeNode = document.getElementById("historical-range");
|
|
const summaryNoteNode = document.getElementById("historical-summary-note");
|
|
const summarySnapshotMetaNode = document.getElementById(
|
|
"historical-summary-snapshot-meta",
|
|
);
|
|
const weeklyTitleNode = document.getElementById("weekly-ranking-title");
|
|
const weeklyStateNode = document.getElementById("weekly-leaderboard-state");
|
|
const weeklyTableNode = document.getElementById("weekly-leaderboard-table");
|
|
const weeklyBodyNode = document.getElementById("weekly-leaderboard-body");
|
|
const weeklyValueHeadingNode = document.getElementById("weekly-leaderboard-value-heading");
|
|
const weeklyWindowNoteNode = document.getElementById("weekly-window-note");
|
|
const weeklySnapshotMetaNode = document.getElementById(
|
|
"weekly-leaderboard-snapshot-meta",
|
|
);
|
|
const recentStateNode = document.getElementById("recent-matches-state");
|
|
const recentListNode = document.getElementById("recent-matches-list");
|
|
const recentNoteNode = document.getElementById("recent-matches-note");
|
|
const recentSnapshotMetaNode = document.getElementById(
|
|
"recent-matches-snapshot-meta",
|
|
);
|
|
recentMatchesPagination = initializeRecentMatchesPagination(recentListNode);
|
|
|
|
const params = new URLSearchParams(window.location.search);
|
|
activeServerSlug = normalizeServerSlug(params.get("server"));
|
|
activeLeaderboardMetric = normalizeLeaderboardMetric(params.get("metric"));
|
|
activeLeaderboardTimeframe = normalizeLeaderboardTimeframe(
|
|
params.get("timeframe"),
|
|
);
|
|
|
|
const summaryCache = new Map();
|
|
const recentMatchesCache = new Map();
|
|
const leaderboardCache = new Map();
|
|
const pendingRequestCache = new Map();
|
|
|
|
const getSummarySnapshot = (serverSlug) =>
|
|
getCachedJson(
|
|
summaryCache,
|
|
pendingRequestCache,
|
|
buildSummarySnapshotKey(serverSlug),
|
|
`${backendBaseUrl}/api/historical/snapshots/server-summary?server=${encodeURIComponent(serverSlug)}`,
|
|
);
|
|
|
|
const getRecentMatchesSnapshot = (serverSlug) =>
|
|
getCachedJson(
|
|
recentMatchesCache,
|
|
pendingRequestCache,
|
|
buildRecentMatchesSnapshotKey(serverSlug),
|
|
`${backendBaseUrl}/api/historical/snapshots/recent-matches?server=${encodeURIComponent(serverSlug)}&limit=${RECENT_MATCHES_LIMIT}`,
|
|
);
|
|
|
|
const getLeaderboardSnapshot = (serverSlug, timeframeKey, metricKey) =>
|
|
getCachedJson(
|
|
leaderboardCache,
|
|
pendingRequestCache,
|
|
buildLeaderboardSnapshotKey(serverSlug, timeframeKey, metricKey),
|
|
`${backendBaseUrl}/api/historical/snapshots/leaderboard?server=${encodeURIComponent(serverSlug)}&timeframe=${encodeURIComponent(timeframeKey)}&metric=${encodeURIComponent(metricKey)}&limit=10`,
|
|
);
|
|
|
|
const refreshServerContent = async () => {
|
|
const requestId = activeServerRequestId + 1;
|
|
const leaderboardRequestId = activeLeaderboardRequestId + 1;
|
|
activeServerRequestId = requestId;
|
|
activeLeaderboardRequestId = leaderboardRequestId;
|
|
const activeMetricConfig = getLeaderboardMetricConfig(activeLeaderboardMetric);
|
|
const activeTimeframeConfig = getLeaderboardTimeframeConfig(
|
|
activeLeaderboardTimeframe,
|
|
);
|
|
const activeServerLabel = getHistoricalServerLabel(activeServerSlug);
|
|
|
|
syncActiveButtons(selectorButtons, activeServerSlug);
|
|
syncLeaderboardTimeframes(
|
|
leaderboardTimeframeButtons,
|
|
activeLeaderboardTimeframe,
|
|
);
|
|
syncLeaderboardTabs(leaderboardTabButtons, activeLeaderboardMetric);
|
|
weeklyTitleNode.textContent = buildLeaderboardTitle(
|
|
activeMetricConfig,
|
|
activeServerSlug,
|
|
activeLeaderboardTimeframe,
|
|
);
|
|
weeklyValueHeadingNode.textContent = activeMetricConfig.valueHeading;
|
|
setRangeBadge(rangeNode, "Cargando rango temporal", false);
|
|
summaryNoteNode.textContent = `La vista esta leyendo datos precalculados del historico local para ${activeServerLabel}.`;
|
|
setSnapshotMeta(summarySnapshotMetaNode, "Cargando datos de resumen...");
|
|
renderSummaryLoading(summaryNode);
|
|
weeklyWindowNoteNode.textContent = "Cargando datos del ranking activo...";
|
|
setSnapshotMeta(
|
|
weeklySnapshotMetaNode,
|
|
`Preparando datos ${activeTimeframeConfig.shortLabel}...`,
|
|
);
|
|
resetRecentMatchesPagination();
|
|
recentListNode.innerHTML = "";
|
|
recentNoteNode.textContent = buildRecentMatchesNote(activeServerSlug);
|
|
setState(recentStateNode, "Cargando partidas recientes...");
|
|
setSnapshotMeta(recentSnapshotMetaNode, "Cargando datos de partidas...");
|
|
|
|
const cachedSummaryPayload = readCachedPayload(
|
|
summaryCache,
|
|
buildSummarySnapshotKey(activeServerSlug),
|
|
);
|
|
if (cachedSummaryPayload) {
|
|
hydrateSummary(
|
|
{ status: "fulfilled", value: cachedSummaryPayload },
|
|
summaryNode,
|
|
rangeNode,
|
|
summaryNoteNode,
|
|
summarySnapshotMetaNode,
|
|
);
|
|
}
|
|
|
|
const cachedLeaderboardPayload = readCachedPayload(
|
|
leaderboardCache,
|
|
buildLeaderboardSnapshotKey(
|
|
activeServerSlug,
|
|
activeLeaderboardTimeframe,
|
|
activeLeaderboardMetric,
|
|
),
|
|
);
|
|
if (cachedLeaderboardPayload) {
|
|
hydrateWeeklyLeaderboard(
|
|
{ status: "fulfilled", value: cachedLeaderboardPayload },
|
|
weeklyStateNode,
|
|
weeklyTableNode,
|
|
weeklyBodyNode,
|
|
weeklyTitleNode,
|
|
weeklyValueHeadingNode,
|
|
weeklyWindowNoteNode,
|
|
weeklySnapshotMetaNode,
|
|
activeMetricConfig,
|
|
activeLeaderboardTimeframe,
|
|
);
|
|
} else {
|
|
setState(
|
|
weeklyStateNode,
|
|
`Cargando ranking ${activeTimeframeConfig.shortLabel}...`,
|
|
);
|
|
weeklyTableNode.hidden = true;
|
|
}
|
|
|
|
const cachedRecentMatchesPayload = readCachedPayload(
|
|
recentMatchesCache,
|
|
buildRecentMatchesSnapshotKey(activeServerSlug),
|
|
);
|
|
if (cachedRecentMatchesPayload) {
|
|
hydrateRecentMatches(
|
|
{ status: "fulfilled", value: cachedRecentMatchesPayload },
|
|
recentStateNode,
|
|
recentListNode,
|
|
recentSnapshotMetaNode,
|
|
);
|
|
}
|
|
|
|
const targetServerSlug = activeServerSlug;
|
|
const targetTimeframe = activeLeaderboardTimeframe;
|
|
const targetMetric = activeLeaderboardMetric;
|
|
void settlePromise(getSummarySnapshot(targetServerSlug)).then((summaryResult) => {
|
|
if (
|
|
!isActiveServerRequest(
|
|
requestId,
|
|
targetServerSlug,
|
|
targetTimeframe,
|
|
targetMetric,
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
hydrateSummary(
|
|
summaryResult,
|
|
summaryNode,
|
|
rangeNode,
|
|
summaryNoteNode,
|
|
summarySnapshotMetaNode,
|
|
);
|
|
});
|
|
|
|
void settlePromise(getRecentMatchesSnapshot(targetServerSlug)).then((recentMatchesResult) => {
|
|
if (
|
|
!isActiveServerRequest(
|
|
requestId,
|
|
targetServerSlug,
|
|
targetTimeframe,
|
|
targetMetric,
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
hydrateRecentMatches(
|
|
recentMatchesResult,
|
|
recentStateNode,
|
|
recentListNode,
|
|
recentSnapshotMetaNode,
|
|
);
|
|
});
|
|
|
|
void settlePromise(
|
|
getLeaderboardSnapshot(targetServerSlug, targetTimeframe, targetMetric),
|
|
).then((leaderboardResult) => {
|
|
if (
|
|
!isActiveLeaderboardRequest(
|
|
requestId,
|
|
leaderboardRequestId,
|
|
targetServerSlug,
|
|
targetTimeframe,
|
|
targetMetric,
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
hydrateWeeklyLeaderboard(
|
|
leaderboardResult,
|
|
weeklyStateNode,
|
|
weeklyTableNode,
|
|
weeklyBodyNode,
|
|
weeklyTitleNode,
|
|
weeklyValueHeadingNode,
|
|
weeklyWindowNoteNode,
|
|
weeklySnapshotMetaNode,
|
|
activeMetricConfig,
|
|
targetTimeframe,
|
|
);
|
|
});
|
|
|
|
};
|
|
|
|
const refreshLeaderboardContent = async () => {
|
|
const requestId = activeLeaderboardRequestId + 1;
|
|
activeLeaderboardRequestId = requestId;
|
|
const metricConfig = getLeaderboardMetricConfig(activeLeaderboardMetric);
|
|
const timeframeConfig = getLeaderboardTimeframeConfig(
|
|
activeLeaderboardTimeframe,
|
|
);
|
|
const targetServerSlug = activeServerSlug;
|
|
const targetTimeframe = activeLeaderboardTimeframe;
|
|
const targetMetric = activeLeaderboardMetric;
|
|
|
|
syncLeaderboardTimeframes(
|
|
leaderboardTimeframeButtons,
|
|
activeLeaderboardTimeframe,
|
|
);
|
|
syncLeaderboardTabs(leaderboardTabButtons, activeLeaderboardMetric);
|
|
weeklyTitleNode.textContent = buildLeaderboardTitle(
|
|
metricConfig,
|
|
activeServerSlug,
|
|
activeLeaderboardTimeframe,
|
|
);
|
|
weeklyValueHeadingNode.textContent = metricConfig.valueHeading;
|
|
|
|
const cachedPayload = readCachedPayload(
|
|
leaderboardCache,
|
|
buildLeaderboardSnapshotKey(
|
|
targetServerSlug,
|
|
targetTimeframe,
|
|
targetMetric,
|
|
),
|
|
);
|
|
if (cachedPayload) {
|
|
hydrateWeeklyLeaderboard(
|
|
{ status: "fulfilled", value: cachedPayload },
|
|
weeklyStateNode,
|
|
weeklyTableNode,
|
|
weeklyBodyNode,
|
|
weeklyTitleNode,
|
|
weeklyValueHeadingNode,
|
|
weeklyWindowNoteNode,
|
|
weeklySnapshotMetaNode,
|
|
metricConfig,
|
|
targetTimeframe,
|
|
);
|
|
return;
|
|
}
|
|
|
|
weeklyWindowNoteNode.textContent = "Cargando datos del ranking activo...";
|
|
setSnapshotMeta(
|
|
weeklySnapshotMetaNode,
|
|
`Cargando datos ${timeframeConfig.shortLabel}...`,
|
|
);
|
|
setState(
|
|
weeklyStateNode,
|
|
`Cargando ranking ${timeframeConfig.shortLabel}...`,
|
|
);
|
|
weeklyTableNode.hidden = true;
|
|
|
|
const leaderboardResult = await settlePromise(
|
|
getLeaderboardSnapshot(targetServerSlug, targetTimeframe, targetMetric),
|
|
);
|
|
|
|
if (
|
|
requestId !== activeLeaderboardRequestId ||
|
|
targetServerSlug !== activeServerSlug ||
|
|
targetTimeframe !== activeLeaderboardTimeframe ||
|
|
targetMetric !== activeLeaderboardMetric
|
|
) {
|
|
return;
|
|
}
|
|
|
|
hydrateWeeklyLeaderboard(
|
|
leaderboardResult,
|
|
weeklyStateNode,
|
|
weeklyTableNode,
|
|
weeklyBodyNode,
|
|
weeklyTitleNode,
|
|
weeklyValueHeadingNode,
|
|
weeklyWindowNoteNode,
|
|
weeklySnapshotMetaNode,
|
|
metricConfig,
|
|
targetTimeframe,
|
|
);
|
|
};
|
|
|
|
selectorButtons.forEach((button) => {
|
|
button.addEventListener("click", () => {
|
|
const nextServerSlug = normalizeServerSlug(button.dataset.serverSlug);
|
|
if (nextServerSlug === activeServerSlug) {
|
|
return;
|
|
}
|
|
|
|
activeServerSlug = nextServerSlug;
|
|
params.set("server", activeServerSlug);
|
|
params.set("timeframe", activeLeaderboardTimeframe);
|
|
params.set("metric", activeLeaderboardMetric);
|
|
window.history.replaceState({}, "", `?${params.toString()}`);
|
|
void refreshServerContent();
|
|
});
|
|
});
|
|
|
|
leaderboardTimeframeButtons.forEach((button) => {
|
|
button.addEventListener("click", () => {
|
|
const nextTimeframe = normalizeLeaderboardTimeframe(
|
|
button.dataset.leaderboardTimeframe,
|
|
);
|
|
if (nextTimeframe === activeLeaderboardTimeframe) {
|
|
return;
|
|
}
|
|
|
|
activeLeaderboardTimeframe = nextTimeframe;
|
|
params.set("server", activeServerSlug);
|
|
params.set("timeframe", activeLeaderboardTimeframe);
|
|
params.set("metric", activeLeaderboardMetric);
|
|
window.history.replaceState({}, "", `?${params.toString()}`);
|
|
void refreshLeaderboardContent();
|
|
});
|
|
});
|
|
|
|
leaderboardTabButtons.forEach((button) => {
|
|
button.addEventListener("click", () => {
|
|
const nextMetric = normalizeLeaderboardMetric(button.dataset.leaderboardMetric);
|
|
if (nextMetric === activeLeaderboardMetric) {
|
|
return;
|
|
}
|
|
|
|
activeLeaderboardMetric = nextMetric;
|
|
params.set("server", activeServerSlug);
|
|
params.set("timeframe", activeLeaderboardTimeframe);
|
|
params.set("metric", activeLeaderboardMetric);
|
|
window.history.replaceState({}, "", `?${params.toString()}`);
|
|
void refreshLeaderboardContent();
|
|
});
|
|
});
|
|
|
|
void refreshServerContent();
|
|
});
|
|
|
|
function isActiveServerRequest(requestId, serverSlug, timeframeKey, metricKey) {
|
|
return (
|
|
requestId === activeServerRequestId &&
|
|
serverSlug === activeServerSlug &&
|
|
timeframeKey === activeLeaderboardTimeframe &&
|
|
metricKey === activeLeaderboardMetric
|
|
);
|
|
}
|
|
|
|
function isActiveLeaderboardRequest(
|
|
serverRequestId,
|
|
leaderboardRequestId,
|
|
serverSlug,
|
|
timeframeKey,
|
|
metricKey,
|
|
) {
|
|
return (
|
|
isActiveServerRequest(serverRequestId, serverSlug, timeframeKey, metricKey) &&
|
|
leaderboardRequestId === activeLeaderboardRequestId
|
|
);
|
|
}
|
|
|
|
function hydrateSummary(result, summaryNode, rangeNode, noteNode, snapshotMetaNode) {
|
|
const emptyState = getHistoricalEmptyState(activeServerSlug);
|
|
if (result.status !== "fulfilled") {
|
|
renderSummaryError(summaryNode);
|
|
setRangeBadge(rangeNode, "Resumen no disponible", false);
|
|
noteNode.textContent =
|
|
"No se pudo leer el resumen precalculado para el alcance seleccionado.";
|
|
setSnapshotMeta(snapshotMetaNode, "Error al leer los datos de resumen.");
|
|
return;
|
|
}
|
|
|
|
const payload = result.value?.data;
|
|
const summary = payload?.item;
|
|
const hasHistoricalData =
|
|
Number(summary?.imported_matches_count ?? summary?.matches_count ?? 0) > 0;
|
|
if (!payload?.found || !summary || !hasHistoricalData) {
|
|
renderSummaryEmpty(summaryNode, emptyState.summaryMessage);
|
|
setRangeBadge(rangeNode, emptyState.rangeLabel, false);
|
|
noteNode.textContent = emptyState.summaryNote;
|
|
setSnapshotMeta(
|
|
snapshotMetaNode,
|
|
payload?.generated_at
|
|
? buildSnapshotMetaText(payload, "Resumen pendiente de generacion.")
|
|
: "Resumen pendiente de generacion.",
|
|
);
|
|
return;
|
|
}
|
|
|
|
const coverage = summary.coverage || {};
|
|
const timeRange = summary.time_range || {};
|
|
const rangeLabel = buildCoverageBadgeLabel(coverage, {
|
|
start: payload?.source_range_start || timeRange.start,
|
|
end: payload?.source_range_end || timeRange.end,
|
|
}, summary.server?.slug);
|
|
setRangeBadge(
|
|
rangeNode,
|
|
rangeLabel || "Cobertura historica disponible",
|
|
coverage.status === "week-plus" && !payload?.is_stale,
|
|
);
|
|
noteNode.textContent = buildSummaryNote(
|
|
"snapshot-precomputed",
|
|
7,
|
|
coverage,
|
|
summary.server?.slug,
|
|
);
|
|
setSnapshotMeta(
|
|
snapshotMetaNode,
|
|
buildSnapshotMetaText(payload, "Resumen sin fecha de actualizacion."),
|
|
);
|
|
summaryNode.innerHTML = [
|
|
renderSummaryCard("Servidor", summary.server?.name || "Servidor no disponible"),
|
|
renderSummaryCard(
|
|
"Partidas registradas",
|
|
formatNumber(summary.imported_matches_count ?? summary.matches_count),
|
|
),
|
|
renderSummaryCard("Jugadores unicos", formatNumber(summary.unique_players)),
|
|
renderSummaryCard(
|
|
"Cobertura historica",
|
|
buildCoveragePeriodLabel(coverage, timeRange, summary.server?.slug),
|
|
),
|
|
renderSummaryCard("Inicio de registro", formatTimestamp(coverage.first_match_at)),
|
|
renderSummaryCard("Ultimo cierre", formatTimestamp(coverage.last_match_at)),
|
|
renderSummaryCard(
|
|
"Mapas frecuentes",
|
|
formatTopMaps(summary.top_maps),
|
|
),
|
|
].join("");
|
|
}
|
|
|
|
function hydrateWeeklyLeaderboard(
|
|
result,
|
|
stateNode,
|
|
tableNode,
|
|
bodyNode,
|
|
titleNode,
|
|
valueHeadingNode,
|
|
noteNode,
|
|
snapshotMetaNode,
|
|
metricConfig,
|
|
timeframeKey,
|
|
) {
|
|
const targetServerSlug = result.value?.data?.server_slug || activeServerSlug;
|
|
const resolvedTimeframeKey = result.value?.data?.timeframe || timeframeKey;
|
|
valueHeadingNode.textContent = metricConfig.valueHeading;
|
|
if (result.status !== "fulfilled") {
|
|
titleNode.textContent = buildLeaderboardTitle(
|
|
metricConfig,
|
|
targetServerSlug,
|
|
resolvedTimeframeKey,
|
|
);
|
|
noteNode.textContent =
|
|
"No se pudo leer los datos precalculados para esta metrica.";
|
|
setSnapshotMeta(snapshotMetaNode, "Error al leer los datos del ranking.");
|
|
setState(
|
|
stateNode,
|
|
`No se pudo cargar el ranking ${getLeaderboardTimeframeConfig(resolvedTimeframeKey).shortLabel}.`,
|
|
true,
|
|
);
|
|
tableNode.hidden = true;
|
|
return;
|
|
}
|
|
|
|
const payload = result.value?.data;
|
|
titleNode.textContent = buildLeaderboardTitle(
|
|
metricConfig,
|
|
payload?.server_slug,
|
|
payload?.timeframe || resolvedTimeframeKey,
|
|
);
|
|
noteNode.textContent = buildWeeklyWindowNote(payload);
|
|
setSnapshotMeta(
|
|
snapshotMetaNode,
|
|
buildSnapshotMetaText(payload, "Ranking pendiente de generacion."),
|
|
);
|
|
if (!payload?.found) {
|
|
setState(
|
|
stateNode,
|
|
buildLeaderboardEmptyMessage(
|
|
metricConfig,
|
|
targetServerSlug,
|
|
payload?.timeframe || resolvedTimeframeKey,
|
|
),
|
|
);
|
|
tableNode.hidden = true;
|
|
return;
|
|
}
|
|
|
|
const items = payload?.items;
|
|
if (!Array.isArray(items) || items.length === 0) {
|
|
setState(
|
|
stateNode,
|
|
buildLeaderboardEmptyMessage(
|
|
metricConfig,
|
|
targetServerSlug,
|
|
payload?.timeframe || resolvedTimeframeKey,
|
|
),
|
|
);
|
|
tableNode.hidden = true;
|
|
return;
|
|
}
|
|
|
|
bodyNode.innerHTML = items
|
|
.map(
|
|
(item) => `
|
|
<tr>
|
|
<td class="historical-table__position">#${escapeHtml(item.ranking_position)}</td>
|
|
<td>${escapeHtml(item.player?.name || "Jugador no identificado")}</td>
|
|
<td>${escapeHtml(formatNumber(item.metric_value))}</td>
|
|
<td>${escapeHtml(formatNumber(item.matches_considered))}</td>
|
|
</tr>
|
|
`,
|
|
)
|
|
.join("");
|
|
stateNode.hidden = true;
|
|
tableNode.hidden = false;
|
|
}
|
|
|
|
function hydrateRecentMatches(result, stateNode, listNode, snapshotMetaNode) {
|
|
const emptyState = getHistoricalEmptyState(activeServerSlug);
|
|
if (result.status !== "fulfilled") {
|
|
setState(stateNode, "No se pudieron cargar las partidas recientes.", true);
|
|
setSnapshotMeta(snapshotMetaNode, "Error al leer los datos de partidas.");
|
|
return;
|
|
}
|
|
|
|
const payload = result.value?.data;
|
|
setSnapshotMeta(
|
|
snapshotMetaNode,
|
|
buildSnapshotMetaText(payload, "Partidas pendientes de generacion."),
|
|
);
|
|
if (!payload?.found) {
|
|
resetRecentMatchesPagination();
|
|
listNode.innerHTML = "";
|
|
setState(stateNode, emptyState.recentMessage);
|
|
return;
|
|
}
|
|
|
|
const items = payload?.items;
|
|
if (!Array.isArray(items) || items.length === 0) {
|
|
resetRecentMatchesPagination();
|
|
listNode.innerHTML = "";
|
|
setState(stateNode, emptyState.recentMessage);
|
|
return;
|
|
}
|
|
|
|
setRecentMatchesPaginationItems(items.slice(0, RECENT_MATCHES_LIMIT), listNode);
|
|
stateNode.hidden = true;
|
|
}
|
|
|
|
function initializeRecentMatchesPagination(listNode) {
|
|
if (!listNode) {
|
|
return null;
|
|
}
|
|
|
|
listNode.insertAdjacentHTML(
|
|
"afterend",
|
|
`
|
|
<div class="historical-pagination" id="recent-matches-pagination" hidden>
|
|
<label class="historical-pagination__size">
|
|
<span>Partidas por pagina</span>
|
|
<select id="recent-matches-page-size" aria-label="Partidas por pagina">
|
|
${RECENT_MATCHES_PAGE_SIZES.map(
|
|
(pageSize) => `
|
|
<option value="${pageSize}"${pageSize === DEFAULT_RECENT_MATCHES_PAGE_SIZE ? " selected" : ""}>
|
|
${pageSize}
|
|
</option>
|
|
`,
|
|
).join("")}
|
|
</select>
|
|
</label>
|
|
<div class="historical-pagination__nav">
|
|
<button class="historical-tab" id="recent-matches-page-prev" type="button">
|
|
Anterior
|
|
</button>
|
|
<p id="recent-matches-page-status">Pagina 1 de 1</p>
|
|
<button class="historical-tab" id="recent-matches-page-next" type="button">
|
|
Siguiente
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`,
|
|
);
|
|
const pagination = {
|
|
items: [],
|
|
page: 1,
|
|
pageSize: DEFAULT_RECENT_MATCHES_PAGE_SIZE,
|
|
root: document.getElementById("recent-matches-pagination"),
|
|
pageSizeSelect: document.getElementById("recent-matches-page-size"),
|
|
previousButton: document.getElementById("recent-matches-page-prev"),
|
|
nextButton: document.getElementById("recent-matches-page-next"),
|
|
status: document.getElementById("recent-matches-page-status"),
|
|
};
|
|
pagination.previousButton?.addEventListener("click", () => {
|
|
if (pagination.page <= 1) {
|
|
return;
|
|
}
|
|
pagination.page -= 1;
|
|
renderRecentMatchesPage(listNode);
|
|
});
|
|
pagination.nextButton?.addEventListener("click", () => {
|
|
if (pagination.page >= getRecentMatchesPageCount(pagination)) {
|
|
return;
|
|
}
|
|
pagination.page += 1;
|
|
renderRecentMatchesPage(listNode);
|
|
});
|
|
pagination.pageSizeSelect?.addEventListener("change", () => {
|
|
pagination.pageSize = normalizeRecentMatchesPageSize(
|
|
pagination.pageSizeSelect.value,
|
|
);
|
|
pagination.page = 1;
|
|
renderRecentMatchesPage(listNode);
|
|
});
|
|
return pagination;
|
|
}
|
|
|
|
function resetRecentMatchesPagination() {
|
|
if (!recentMatchesPagination) {
|
|
return;
|
|
}
|
|
|
|
recentMatchesPagination.items = [];
|
|
recentMatchesPagination.page = 1;
|
|
recentMatchesPagination.pageSize = DEFAULT_RECENT_MATCHES_PAGE_SIZE;
|
|
if (recentMatchesPagination.pageSizeSelect) {
|
|
recentMatchesPagination.pageSizeSelect.value = String(
|
|
DEFAULT_RECENT_MATCHES_PAGE_SIZE,
|
|
);
|
|
}
|
|
if (recentMatchesPagination.root) {
|
|
recentMatchesPagination.root.hidden = true;
|
|
}
|
|
}
|
|
|
|
function setRecentMatchesPaginationItems(items, listNode) {
|
|
if (!recentMatchesPagination) {
|
|
listNode.innerHTML = items.map((item) => renderRecentMatchCard(item)).join("");
|
|
return;
|
|
}
|
|
|
|
recentMatchesPagination.items = items;
|
|
recentMatchesPagination.page = 1;
|
|
renderRecentMatchesPage(listNode);
|
|
}
|
|
|
|
function renderRecentMatchesPage(listNode) {
|
|
const pagination = recentMatchesPagination;
|
|
if (!pagination) {
|
|
return;
|
|
}
|
|
|
|
const pageCount = getRecentMatchesPageCount(pagination);
|
|
pagination.page = Math.min(Math.max(1, pagination.page), pageCount);
|
|
const pageStart = (pagination.page - 1) * pagination.pageSize;
|
|
const visibleItems = pagination.items.slice(pageStart, pageStart + pagination.pageSize);
|
|
listNode.innerHTML = visibleItems.map((item) => renderRecentMatchCard(item)).join("");
|
|
if (pagination.status) {
|
|
pagination.status.textContent = `Pagina ${pagination.page} de ${pageCount}`;
|
|
}
|
|
if (pagination.previousButton) {
|
|
pagination.previousButton.disabled = pagination.page <= 1;
|
|
}
|
|
if (pagination.nextButton) {
|
|
pagination.nextButton.disabled = pagination.page >= pageCount;
|
|
}
|
|
if (pagination.root) {
|
|
pagination.root.hidden = pagination.items.length <= DEFAULT_RECENT_MATCHES_PAGE_SIZE;
|
|
}
|
|
}
|
|
|
|
function getRecentMatchesPageCount(pagination) {
|
|
return Math.max(1, Math.ceil(pagination.items.length / pagination.pageSize));
|
|
}
|
|
|
|
function normalizeRecentMatchesPageSize(rawValue) {
|
|
const pageSize = Number(rawValue);
|
|
return RECENT_MATCHES_PAGE_SIZES.includes(pageSize)
|
|
? pageSize
|
|
: DEFAULT_RECENT_MATCHES_PAGE_SIZE;
|
|
}
|
|
|
|
function hydrateMonthlyMvp(
|
|
result,
|
|
stateNode,
|
|
listNode,
|
|
titleNode,
|
|
noteNode,
|
|
snapshotMetaNode,
|
|
) {
|
|
if (result.status !== "fulfilled") {
|
|
titleNode.textContent = `Top 3 MVP mensual V1 - ${getHistoricalServerLabel(activeServerSlug)}`;
|
|
noteNode.textContent = "No se pudo leer el registro mensual del MVP V1.";
|
|
setSnapshotMeta(snapshotMetaNode, "Error al leer el registro del MVP mensual V1.");
|
|
setState(stateNode, "No se pudo cargar el Top 3 MVP mensual V1.", true);
|
|
listNode.innerHTML = "";
|
|
return;
|
|
}
|
|
|
|
const payload = result.value?.data;
|
|
titleNode.textContent = `Top 3 MVP mensual V1 - ${getHistoricalServerLabel(
|
|
payload?.server_slug || activeServerSlug,
|
|
)}`;
|
|
noteNode.textContent = buildMonthlyMvpNote(payload);
|
|
setSnapshotMeta(
|
|
snapshotMetaNode,
|
|
buildSnapshotMetaText(payload, "Registro del MVP mensual V1 pendiente de generacion."),
|
|
);
|
|
|
|
if (!payload?.found) {
|
|
setState(
|
|
stateNode,
|
|
"Todavia no hay un Top 3 MVP mensual V1 listo para el alcance activo.",
|
|
);
|
|
listNode.innerHTML = "";
|
|
return;
|
|
}
|
|
|
|
const items = payload?.items;
|
|
if (!Array.isArray(items) || items.length === 0) {
|
|
setState(
|
|
stateNode,
|
|
"No hay jugadores elegibles para el MVP mensual en el periodo activo.",
|
|
);
|
|
listNode.innerHTML = "";
|
|
return;
|
|
}
|
|
|
|
listNode.innerHTML = items.map((item) => renderMonthlyMvpCard(item, payload)).join("");
|
|
stateNode.hidden = true;
|
|
}
|
|
|
|
function hydrateMonthlyMvpV2(
|
|
result,
|
|
stateNode,
|
|
listNode,
|
|
titleNode,
|
|
noteNode,
|
|
snapshotMetaNode,
|
|
) {
|
|
if (result.status !== "fulfilled") {
|
|
titleNode.textContent = `Top 3 MVP mensual V2 - ${getHistoricalServerLabel(activeServerSlug)}`;
|
|
noteNode.textContent = "No se pudo leer el registro mensual del MVP V2.";
|
|
setSnapshotMeta(snapshotMetaNode, "Error al leer el registro del MVP mensual V2.");
|
|
setState(stateNode, "No se pudo cargar el Top 3 MVP mensual V2.", true);
|
|
listNode.innerHTML = "";
|
|
return;
|
|
}
|
|
|
|
const payload = result.value?.data;
|
|
titleNode.textContent = `Top 3 MVP mensual V2 - ${getHistoricalServerLabel(
|
|
payload?.server_slug || activeServerSlug,
|
|
)}`;
|
|
noteNode.textContent = buildMonthlyMvpV2Note(payload);
|
|
setSnapshotMeta(
|
|
snapshotMetaNode,
|
|
buildSnapshotMetaText(payload, "Registro del MVP mensual V2 pendiente de generacion."),
|
|
);
|
|
|
|
if (!payload?.found) {
|
|
setState(
|
|
stateNode,
|
|
"Todavia no hay un Top 3 MVP mensual V2 listo para el alcance activo.",
|
|
);
|
|
listNode.innerHTML = "";
|
|
return;
|
|
}
|
|
|
|
const items = payload?.items;
|
|
if (!Array.isArray(items) || items.length === 0) {
|
|
setState(
|
|
stateNode,
|
|
"No hay jugadores elegibles para el MVP mensual V2 en el periodo activo.",
|
|
);
|
|
listNode.innerHTML = "";
|
|
return;
|
|
}
|
|
|
|
listNode.innerHTML = items.map((item) => renderMonthlyMvpV2Card(item, payload)).join("");
|
|
stateNode.hidden = true;
|
|
}
|
|
|
|
function hydrateMvpComparison(
|
|
monthlyMvpResult,
|
|
monthlyMvpV2Result,
|
|
stateNode,
|
|
listNode,
|
|
noteNode,
|
|
) {
|
|
if (!monthlyMvpResult || !monthlyMvpV2Result) {
|
|
setState(stateNode, "Preparando comparativa V1 vs V2...");
|
|
return;
|
|
}
|
|
|
|
if (
|
|
monthlyMvpResult.status !== "fulfilled" ||
|
|
monthlyMvpV2Result.status !== "fulfilled"
|
|
) {
|
|
noteNode.textContent = "No se pudo completar la lectura conjunta de V1 y V2.";
|
|
setState(stateNode, "No se pudo cargar la comparativa V1 vs V2.", true);
|
|
listNode.innerHTML = "";
|
|
return;
|
|
}
|
|
|
|
const v1Payload = monthlyMvpResult.value?.data;
|
|
const v2Payload = monthlyMvpV2Result.value?.data;
|
|
const v1Items = Array.isArray(v1Payload?.items) ? v1Payload.items : [];
|
|
const v2Items = Array.isArray(v2Payload?.items) ? v2Payload.items : [];
|
|
|
|
if (!v1Payload?.found || !v2Payload?.found || !v1Items.length || !v2Items.length) {
|
|
noteNode.textContent =
|
|
"La comparativa se activara cuando existan rankings V1 y V2 listos para el mismo alcance.";
|
|
setState(stateNode, "Todavia no hay una comparativa V1 vs V2 lista para este alcance.");
|
|
listNode.innerHTML = "";
|
|
return;
|
|
}
|
|
|
|
const comparisonItems = buildMvpComparisonItems(v1Items, v2Items);
|
|
if (!comparisonItems.length) {
|
|
noteNode.textContent =
|
|
"No se encontraron jugadores coincidentes o relevantes para comparar entre V1 y V2.";
|
|
setState(stateNode, "Sin diferencias comparables entre V1 y V2 para el alcance activo.");
|
|
listNode.innerHTML = "";
|
|
return;
|
|
}
|
|
|
|
noteNode.textContent = buildMvpComparisonNote(v1Payload, v2Payload, comparisonItems.length);
|
|
listNode.innerHTML = comparisonItems
|
|
.map((item) => renderMvpComparisonCard(item))
|
|
.join("");
|
|
stateNode.hidden = true;
|
|
}
|
|
|
|
function renderRecentMatchCard(item) {
|
|
const mapName = item.map?.pretty_name || item.map?.name || "Mapa no disponible";
|
|
const detailUrl = buildInternalMatchDetailUrl(item);
|
|
const actionLinks = [
|
|
`<span class="historical-match-card__result">${escapeHtml(formatMatchResult(item.result))}</span>`,
|
|
detailUrl
|
|
? `
|
|
<a
|
|
class="historical-match-card__link"
|
|
href="${escapeHtml(detailUrl)}"
|
|
>
|
|
Ver detalles
|
|
</a>
|
|
`
|
|
: "",
|
|
].join("");
|
|
return `
|
|
<article class="historical-match-card historical-match-card--clean">
|
|
<div class="historical-match-card__top historical-match-card__top--clean">
|
|
<h3 class="historical-match-card__title">${escapeHtml(mapName)}</h3>
|
|
</div>
|
|
|
|
<div class="historical-match-meta historical-match-meta--clean">
|
|
<article>
|
|
<p class="historical-match-meta__label">Servidor</p>
|
|
<strong>${escapeHtml(item.server?.name || "Servidor no disponible")}</strong>
|
|
</article>
|
|
<article>
|
|
<p class="historical-match-meta__label">Cierre</p>
|
|
<strong>${escapeHtml(formatTimestamp(item.closed_at))}</strong>
|
|
</article>
|
|
<article>
|
|
<p class="historical-match-meta__label">Jugadores</p>
|
|
<strong>${escapeHtml(formatNumber(item.player_count))}</strong>
|
|
</article>
|
|
<article>
|
|
<p class="historical-match-meta__label">Marcador</p>
|
|
<strong>${escapeHtml(formatScore(item.result))}</strong>
|
|
</article>
|
|
<article class="historical-match-card__actions-cell" aria-label="Acciones de la partida">
|
|
<div class="historical-match-card__actions">
|
|
${actionLinks}
|
|
</div>
|
|
</article>
|
|
</div>
|
|
</article>
|
|
`;
|
|
}
|
|
|
|
function normalizeExternalMatchUrl(value) {
|
|
if (typeof value !== "string" || !value.trim()) {
|
|
return "";
|
|
}
|
|
try {
|
|
const url = new URL(value.trim());
|
|
return ["http:", "https:"].includes(url.protocol) ? url.href : "";
|
|
} catch (error) {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function buildInternalMatchDetailUrl(item) {
|
|
const serverSlug = item?.server?.slug;
|
|
const matchId = item?.internal_detail_match_id || item?.match_id;
|
|
if (typeof serverSlug !== "string" || !serverSlug.trim()) {
|
|
return "";
|
|
}
|
|
if (typeof matchId !== "string" && typeof matchId !== "number") {
|
|
return "";
|
|
}
|
|
const normalizedMatchId = String(matchId).trim();
|
|
if (!normalizedMatchId) {
|
|
return "";
|
|
}
|
|
return `./historico-partida.html?server=${encodeURIComponent(
|
|
serverSlug.trim(),
|
|
)}&match=${encodeURIComponent(normalizedMatchId)}`;
|
|
}
|
|
|
|
function renderSummaryLoading(summaryNode) {
|
|
summaryNode.innerHTML = renderSummaryCard("Estado", "Cargando datos historicos");
|
|
}
|
|
|
|
function renderSummaryError(summaryNode) {
|
|
summaryNode.innerHTML = renderSummaryCard("Estado", "Error al cargar el resumen");
|
|
}
|
|
|
|
function renderSummaryEmpty(summaryNode, message = "Sin datos historicos suficientes") {
|
|
summaryNode.innerHTML = renderSummaryCard("Estado", message);
|
|
}
|
|
|
|
function renderSummaryCard(label, value) {
|
|
return `
|
|
<article class="historical-stat-card">
|
|
<p>${escapeHtml(label)}</p>
|
|
<strong>${escapeHtml(value)}</strong>
|
|
</article>
|
|
`;
|
|
}
|
|
|
|
function renderMonthlyMvpCard(item, payload) {
|
|
const scoreValue = Number(item?.mvp_score);
|
|
return `
|
|
<article class="historical-mvp-card historical-mvp-card--rank-${escapeHtml(item?.ranking_position || "x")}">
|
|
<div class="historical-mvp-card__top">
|
|
<div>
|
|
<span class="historical-mvp-card__rank">#${escapeHtml(item?.ranking_position || "-")}</span>
|
|
</div>
|
|
<div>
|
|
<p class="historical-mvp-card__score-label">Puntuacion MVP</p>
|
|
<strong class="historical-mvp-card__score-value">${escapeHtml(
|
|
Number.isFinite(scoreValue) ? scoreValue.toFixed(1) : "0.0",
|
|
)}</strong>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<strong class="historical-mvp-card__player">${escapeHtml(
|
|
item?.player?.name || "Jugador no identificado",
|
|
)}</strong>
|
|
</div>
|
|
<div class="historical-mvp-card__meta">
|
|
<article>
|
|
<span>Kills</span>
|
|
<strong>${escapeHtml(formatNumber(item?.totals?.kills))}</strong>
|
|
</article>
|
|
<article>
|
|
<span>Soporte</span>
|
|
<strong>${escapeHtml(formatNumber(item?.totals?.support))}</strong>
|
|
</article>
|
|
<article>
|
|
<span>KPM</span>
|
|
<strong>${escapeHtml(formatDecimal(item?.derived?.kpm, 2))}</strong>
|
|
</article>
|
|
<article>
|
|
<span>KDA</span>
|
|
<strong>${escapeHtml(formatDecimal(item?.derived?.kda, 2))}</strong>
|
|
</article>
|
|
</div>
|
|
<p class="historical-mvp-card__footer">
|
|
${escapeHtml(buildMonthlyMvpFooter(item, payload))}
|
|
</p>
|
|
</article>
|
|
`;
|
|
}
|
|
|
|
function renderMonthlyMvpV2Card(item, payload) {
|
|
const scoreValue = Number(item?.mvp_v2_score);
|
|
return `
|
|
<article class="historical-mvp-card historical-mvp-card--v2 historical-mvp-card--rank-${escapeHtml(item?.ranking_position || "x")}">
|
|
<div class="historical-mvp-card__top">
|
|
<div>
|
|
<span class="historical-mvp-card__rank">#${escapeHtml(item?.ranking_position || "-")}</span>
|
|
</div>
|
|
<div>
|
|
<p class="historical-mvp-card__score-label">Puntuacion MVP V2</p>
|
|
<strong class="historical-mvp-card__score-value">${escapeHtml(
|
|
Number.isFinite(scoreValue) ? scoreValue.toFixed(1) : "0.0",
|
|
)}</strong>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<span class="historical-mvp-card__version">V2 avanzado</span>
|
|
</div>
|
|
<div>
|
|
<strong class="historical-mvp-card__player">${escapeHtml(
|
|
item?.player?.name || "Jugador no identificado",
|
|
)}</strong>
|
|
</div>
|
|
<div class="historical-mvp-card__signals">
|
|
<p class="historical-mvp-card__signal-summary">${escapeHtml(
|
|
buildMonthlyMvpV2SignalSummary(item),
|
|
)}</p>
|
|
<div class="historical-mvp-card__signal-grid">
|
|
<article>
|
|
<span>Penalty TK</span>
|
|
<strong>${escapeHtml(formatDecimal(item?.teamkill_penalty_v2, 2))}</strong>
|
|
</article>
|
|
<article>
|
|
<span>Confidence</span>
|
|
<strong>${escapeHtml(formatPercent(item?.advanced_confidence))}</strong>
|
|
</article>
|
|
<article>
|
|
<span>Most killed</span>
|
|
<strong>${escapeHtml(formatNumber(item?.advanced?.most_killed_count))}</strong>
|
|
</article>
|
|
<article>
|
|
<span>Duel control</span>
|
|
<strong>${escapeHtml(formatNumber(item?.advanced?.duel_control_raw))}</strong>
|
|
</article>
|
|
</div>
|
|
</div>
|
|
<p class="historical-mvp-card__footer">
|
|
${escapeHtml(buildMonthlyMvpV2Footer(item, payload))}
|
|
</p>
|
|
</article>
|
|
`;
|
|
}
|
|
|
|
function renderMvpComparisonCard(item) {
|
|
return `
|
|
<article class="historical-comparison-card">
|
|
<div class="historical-comparison-card__top">
|
|
<div>
|
|
<p class="historical-comparison-card__eyebrow">Jugador comparado</p>
|
|
<h3 class="historical-comparison-card__title">${escapeHtml(item.playerName)}</h3>
|
|
</div>
|
|
<div>
|
|
<p class="historical-comparison-card__delta-label">Delta puesto</p>
|
|
<strong class="historical-comparison-card__delta-value">${escapeHtml(item.positionDeltaLabel)}</strong>
|
|
</div>
|
|
</div>
|
|
<div class="historical-comparison-card__scores">
|
|
<div class="historical-comparison-card__score-block">
|
|
<p class="historical-comparison-card__delta-label">Score V1</p>
|
|
<strong>${escapeHtml(item.v1ScoreLabel)}</strong>
|
|
</div>
|
|
<div class="historical-comparison-card__score-block">
|
|
<p class="historical-comparison-card__delta-label">Score V2</p>
|
|
<strong>${escapeHtml(item.v2ScoreLabel)}</strong>
|
|
</div>
|
|
</div>
|
|
<div class="historical-comparison-card__meta">
|
|
<article>
|
|
<span>Posicion V1</span>
|
|
<strong>${escapeHtml(item.v1PositionLabel)}</strong>
|
|
</article>
|
|
<article>
|
|
<span>Posicion V2</span>
|
|
<strong>${escapeHtml(item.v2PositionLabel)}</strong>
|
|
</article>
|
|
<article>
|
|
<span>Delta score</span>
|
|
<strong>${escapeHtml(item.scoreDeltaLabel)}</strong>
|
|
</article>
|
|
<article>
|
|
<span>Penalty TK</span>
|
|
<strong>${escapeHtml(item.teamkillPenaltyLabel)}</strong>
|
|
</article>
|
|
</div>
|
|
<p class="historical-comparison-card__summary">${escapeHtml(item.summary)}</p>
|
|
</article>
|
|
`;
|
|
}
|
|
|
|
function hydrateEloMmr(result, stateNode, listNode, noteNode, metaNode) {
|
|
if (result?.status !== "fulfilled") {
|
|
setState(stateNode, "No se pudo cargar el leaderboard Elo/MMR.", true);
|
|
listNode.innerHTML = "";
|
|
noteNode.textContent =
|
|
"El sistema Elo/MMR sigue disponible solo cuando existe rebuild persistido.";
|
|
setSnapshotMeta(metaNode, "Error al cargar metadata de Elo/MMR.");
|
|
return;
|
|
}
|
|
|
|
const payload = result.value?.data;
|
|
const items = Array.isArray(payload?.items) ? payload.items : [];
|
|
if (!payload?.found || !items.length) {
|
|
setState(
|
|
stateNode,
|
|
"Todavia no hay leaderboard Elo/MMR mensual listo para este alcance.",
|
|
);
|
|
listNode.innerHTML = "";
|
|
noteNode.textContent =
|
|
"El bloque aparece cuando existe un rebuild persistido con jugadores elegibles.";
|
|
setSnapshotMeta(
|
|
metaNode,
|
|
buildEloMmrMeta(payload),
|
|
);
|
|
return;
|
|
}
|
|
|
|
stateNode.hidden = true;
|
|
noteNode.textContent = buildEloMmrNote(payload);
|
|
setSnapshotMeta(metaNode, buildEloMmrMeta(payload));
|
|
listNode.innerHTML = items.map((item) => renderEloMmrCard(item, payload)).join("");
|
|
}
|
|
|
|
function renderEloMmrCard(item, payload) {
|
|
return `
|
|
<article class="historical-elo-card">
|
|
<div class="historical-elo-card__top">
|
|
<div>
|
|
<span class="historical-elo-card__rank">#${escapeHtml(item?.ranking_position || "-")}</span>
|
|
</div>
|
|
<div>
|
|
<span class="historical-elo-card__accuracy">${escapeHtml(formatAccuracyMode(item?.accuracy_mode))}</span>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<strong class="historical-mvp-card__player">${escapeHtml(
|
|
item?.player?.name || "Jugador no identificado",
|
|
)}</strong>
|
|
</div>
|
|
<div class="historical-elo-card__scores">
|
|
<article>
|
|
<span>Score mensual</span>
|
|
<strong>${escapeHtml(formatDecimal(item?.monthly_rank_score, 2))}</strong>
|
|
</article>
|
|
<article>
|
|
<span>MMR persistente</span>
|
|
<strong>${escapeHtml(formatDecimal(item?.persistent_rating?.mmr, 1))}</strong>
|
|
</article>
|
|
</div>
|
|
<div class="historical-elo-card__meta">
|
|
<article>
|
|
<span>MMR gain</span>
|
|
<strong>${escapeHtml(formatSignedDecimal(item?.persistent_rating?.mmr_gain, 1))}</strong>
|
|
</article>
|
|
<article>
|
|
<span>Elegibilidad</span>
|
|
<strong>${escapeHtml(item?.eligible ? "Elegible" : "Parcial")}</strong>
|
|
</article>
|
|
<article>
|
|
<span>Partidas validas</span>
|
|
<strong>${escapeHtml(formatNumber(item?.valid_matches))}</strong>
|
|
</article>
|
|
<article>
|
|
<span>Confidence</span>
|
|
<strong>${escapeHtml(formatDecimal(item?.components?.confidence, 1))}</strong>
|
|
</article>
|
|
</div>
|
|
<p class="historical-elo-card__summary">${escapeHtml(buildEloMmrSummary(item))}</p>
|
|
<p class="historical-elo-card__footer">${escapeHtml(buildEloMmrFooter(item, payload))}</p>
|
|
</article>
|
|
`;
|
|
}
|
|
|
|
function setState(node, message, isError = false) {
|
|
node.textContent = message;
|
|
node.hidden = false;
|
|
node.classList.toggle("is-error", isError);
|
|
}
|
|
|
|
function setRangeBadge(node, label, isFresh) {
|
|
node.textContent = label;
|
|
node.classList.toggle("status-chip--ok", isFresh);
|
|
node.classList.toggle("status-chip--fallback", !isFresh);
|
|
}
|
|
|
|
function setSnapshotMeta(node, message) {
|
|
node.textContent = message;
|
|
}
|
|
|
|
function syncActiveButtons(buttons, activeServerSlug) {
|
|
buttons.forEach((button) => {
|
|
button.classList.toggle(
|
|
"is-active",
|
|
button.dataset.serverSlug === activeServerSlug,
|
|
);
|
|
});
|
|
}
|
|
|
|
function syncLeaderboardTabs(buttons, activeMetric) {
|
|
buttons.forEach((button) => {
|
|
const isActive = button.dataset.leaderboardMetric === activeMetric;
|
|
button.classList.toggle("is-active", isActive);
|
|
button.setAttribute("aria-selected", String(isActive));
|
|
});
|
|
}
|
|
|
|
function syncLeaderboardTimeframes(buttons, activeTimeframe) {
|
|
buttons.forEach((button) => {
|
|
const isActive = button.dataset.leaderboardTimeframe === activeTimeframe;
|
|
button.classList.toggle("is-active", isActive);
|
|
button.setAttribute("aria-selected", String(isActive));
|
|
});
|
|
}
|
|
|
|
function normalizeServerSlug(rawValue) {
|
|
const normalized = typeof rawValue === "string" ? rawValue.trim() : "";
|
|
if (HISTORICAL_SERVER_SLUGS.includes(normalized)) {
|
|
return normalized;
|
|
}
|
|
|
|
return DEFAULT_HISTORICAL_SERVER;
|
|
}
|
|
|
|
function getHistoricalServerLabel(serverSlug) {
|
|
return (
|
|
HISTORICAL_SERVERS.find((server) => server.slug === serverSlug)?.label ||
|
|
HISTORICAL_SERVERS[0].label
|
|
);
|
|
}
|
|
|
|
function normalizeLeaderboardMetric(rawValue) {
|
|
const normalized = typeof rawValue === "string" ? rawValue.trim() : "";
|
|
if (LEADERBOARD_METRICS.some((metric) => metric.key === normalized)) {
|
|
return normalized;
|
|
}
|
|
|
|
return DEFAULT_LEADERBOARD_METRIC;
|
|
}
|
|
|
|
function normalizeLeaderboardTimeframe(rawValue) {
|
|
const normalized = typeof rawValue === "string" ? rawValue.trim() : "";
|
|
if (LEADERBOARD_TIMEFRAMES.some((timeframe) => timeframe.key === normalized)) {
|
|
return normalized;
|
|
}
|
|
|
|
return DEFAULT_LEADERBOARD_TIMEFRAME;
|
|
}
|
|
|
|
function getLeaderboardMetricConfig(metricKey) {
|
|
return (
|
|
LEADERBOARD_METRICS.find((metric) => metric.key === metricKey) ||
|
|
LEADERBOARD_METRICS[0]
|
|
);
|
|
}
|
|
|
|
function getLeaderboardTimeframeConfig(timeframeKey) {
|
|
return (
|
|
LEADERBOARD_TIMEFRAMES.find((timeframe) => timeframe.key === timeframeKey) ||
|
|
LEADERBOARD_TIMEFRAMES[0]
|
|
);
|
|
}
|
|
|
|
function buildSummarySnapshotKey(serverSlug) {
|
|
return `summary:${serverSlug}`;
|
|
}
|
|
|
|
function buildRecentMatchesSnapshotKey(serverSlug) {
|
|
return `recent:${serverSlug}`;
|
|
}
|
|
|
|
function buildLeaderboardSnapshotKey(serverSlug, timeframeKey, metricKey) {
|
|
return `leaderboard:${serverSlug}:${timeframeKey}:${metricKey}`;
|
|
}
|
|
|
|
function buildMonthlyMvpSnapshotKey(serverSlug) {
|
|
return `monthly-mvp:${serverSlug}`;
|
|
}
|
|
|
|
function buildMonthlyMvpV2SnapshotKey(serverSlug) {
|
|
return `monthly-mvp-v2:${serverSlug}`;
|
|
}
|
|
|
|
function buildEloMmrSnapshotKey(serverSlug) {
|
|
return `elo-mmr:${serverSlug}`;
|
|
}
|
|
|
|
function buildRangeLabel(start, end) {
|
|
if (!start && !end) {
|
|
return "";
|
|
}
|
|
|
|
return `${formatTimestamp(start)} a ${formatTimestamp(end)}`;
|
|
}
|
|
|
|
function buildCoverageBadgeLabel(coverage, timeRange, serverSlug) {
|
|
const rangeStart = coverage?.first_match_at || timeRange?.start;
|
|
const rangeEnd = coverage?.last_match_at || timeRange?.end;
|
|
if (!rangeStart && !rangeEnd) {
|
|
return "Sin cobertura registrada";
|
|
}
|
|
if (coverage?.status === "under-week") {
|
|
return "Cobertura inicial";
|
|
}
|
|
if (coverage?.status === "week-plus") {
|
|
return "Cobertura historica";
|
|
}
|
|
return "Periodo registrado";
|
|
}
|
|
|
|
function buildCoveragePeriodLabel(coverage, timeRange, serverSlug) {
|
|
const start = coverage?.first_match_at || timeRange?.start;
|
|
const end = coverage?.last_match_at || timeRange?.end;
|
|
if (start && end) {
|
|
return `Desde ${formatDateOnly(start)} hasta ${formatDateOnly(end)}`;
|
|
}
|
|
if (start) {
|
|
return `Desde ${formatDateOnly(start)}`;
|
|
}
|
|
if (end) {
|
|
return `Hasta ${formatDateOnly(end)}`;
|
|
}
|
|
return "Sin cobertura registrada";
|
|
}
|
|
|
|
function buildSummaryNote(summaryBasis, weeklyWindowDays, coverage, serverSlug) {
|
|
const basisLabel =
|
|
summaryBasis === "snapshot-precomputed"
|
|
? "el historico local"
|
|
: "el historico persistido disponible";
|
|
const status = coverage?.status;
|
|
void weeklyWindowDays;
|
|
void serverSlug;
|
|
if (status === "under-week") {
|
|
return `Este bloque resume ${basisLabel}. La cobertura registrada todavia es inicial y puede crecer en los proximos dias.`;
|
|
}
|
|
if (serverSlug === "all-servers") {
|
|
return `Resumen de los servidores desde ${basisLabel}, combinado solo con los servidores actuales de la comunidad.`;
|
|
}
|
|
return `Resumen servido desde ${basisLabel}.`;
|
|
}
|
|
|
|
function buildWeeklyWindowNote(payload) {
|
|
if (!payload?.found) {
|
|
const timeframeLabel = getLeaderboardTimeframeConfig(
|
|
payload?.timeframe || activeLeaderboardTimeframe,
|
|
).shortLabel;
|
|
return `No existen datos en ${timeframeLabel} suficientes para esta metrica en el rango activo.`;
|
|
}
|
|
|
|
const start = formatTimestamp(payload?.window_start);
|
|
const end = formatTimestamp(payload?.window_end);
|
|
const windowLabel =
|
|
payload?.window_label ||
|
|
(payload?.timeframe === "monthly" ? "Mes activo" : "Semana activa");
|
|
if (payload?.uses_fallback) {
|
|
return `${windowLabel}: ${start} a ${end}.`;
|
|
}
|
|
return `${windowLabel}: ${start} a ${end}.`;
|
|
}
|
|
|
|
function buildLeaderboardTitle(metricConfig, serverSlug, timeframeKey) {
|
|
const timeframeLabel = getLeaderboardTimeframeConfig(timeframeKey).label;
|
|
return `${metricConfig.title} ${timeframeLabel} - ${getHistoricalServerLabel(serverSlug)}`;
|
|
}
|
|
|
|
function buildRecentMatchesNote(serverSlug) {
|
|
if (serverSlug === "all-servers") {
|
|
return "Lista de cierres ya registrados para los servidores con historico disponible.";
|
|
}
|
|
return `Lista de cierres ya registrados para ${getHistoricalServerLabel(serverSlug)}.`;
|
|
}
|
|
|
|
function buildMonthlyMvpNote(payload) {
|
|
if (!payload?.found) {
|
|
return "El Top 3 mensual V1 aparecera cuando exista un registro MVP listo para este alcance.";
|
|
}
|
|
const periodLabel =
|
|
payload?.window_label && payload?.month_key
|
|
? `${payload.window_label} (${formatMonthKey(payload.month_key)})`
|
|
: formatMonthKey(payload?.month_key);
|
|
const eligiblePlayers = formatNumber(payload?.eligible_players_count);
|
|
return `${periodLabel || "Periodo mensual activo"}. ${eligiblePlayers} jugadores cumplen los umbrales base de la version V1.`;
|
|
}
|
|
|
|
function buildMonthlyMvpFooter(item, payload) {
|
|
const hoursPlayed = Number(item?.totals?.time_seconds) / 3600;
|
|
const monthLabel = formatMonthKey(payload?.month_key);
|
|
return `${monthLabel || "Mes activo"} · ${formatNumber(
|
|
item?.matches_considered,
|
|
)} partidas · ${formatDecimal(hoursPlayed, 1)} h jugadas`;
|
|
}
|
|
|
|
function buildMonthlyMvpV2Note(payload) {
|
|
if (!payload?.found) {
|
|
return "El Top 3 mensual V2 aparecera cuando exista un registro alineado con la cobertura de eventos.";
|
|
}
|
|
const periodLabel =
|
|
payload?.window_label && payload?.month_key
|
|
? `${payload.window_label} (${formatMonthKey(payload.month_key)})`
|
|
: formatMonthKey(payload?.month_key);
|
|
const eligiblePlayers = formatNumber(payload?.eligible_players_count);
|
|
const eventCount = formatNumber(payload?.event_coverage?.event_count);
|
|
return `${periodLabel || "Periodo mensual activo"}. ${eligiblePlayers} jugadores elegibles y ${eventCount} eventos V2 cubiertos para este alcance.`;
|
|
}
|
|
|
|
function buildMonthlyMvpV2SignalSummary(item) {
|
|
const rivalryEdge = formatNumber(item?.advanced?.rivalry_edge_raw);
|
|
const deathBy = formatNumber(item?.advanced?.death_by_count);
|
|
return `Ventaja de rivalidad ${rivalryEdge} y ${deathBy} muertes frente a su rival mas repetido.`;
|
|
}
|
|
|
|
function buildMonthlyMvpV2Footer(item, payload) {
|
|
const hoursPlayed = Number(item?.totals?.time_seconds) / 3600;
|
|
const monthLabel = formatMonthKey(payload?.month_key);
|
|
return `${monthLabel || "Mes activo"} · ${formatNumber(
|
|
item?.matches_considered,
|
|
)} partidas · ${formatDecimal(hoursPlayed, 1)} h jugadas`;
|
|
}
|
|
|
|
function buildEloMmrNote(payload) {
|
|
const monthLabel = formatMonthKey(payload?.month_key);
|
|
const exactRatio = Number(payload?.capabilities_summary?.exact_ratio || 0);
|
|
const approximateRatio = Number(payload?.capabilities_summary?.approximate_ratio || 0);
|
|
return `${monthLabel || "Mes activo"}. Rating persistente + score mensual con ${formatPercent(exactRatio)} de senal exacta y ${formatPercent(approximateRatio)} de senal aproximada en este corte.`;
|
|
}
|
|
|
|
function buildEloMmrMeta(payload) {
|
|
const sourceLabel = payload?.selected_source || payload?.source || "origen no disponible";
|
|
const fallbackLabel = payload?.fallback_used
|
|
? `fallback ${payload?.fallback_reason || "activo"}`
|
|
: "sin fallback";
|
|
return `Generado ${formatTimestamp(payload?.generated_at)} · fuente ${sourceLabel} · ${fallbackLabel}`;
|
|
}
|
|
|
|
function buildEloMmrSummary(item) {
|
|
return `AvgMatchScore ${formatDecimal(item?.components?.avg_match_score, 1)}, actividad ${formatDecimal(item?.components?.activity, 1)} y strength-of-schedule ${formatDecimal(item?.components?.strength_of_schedule, 1)}.`;
|
|
}
|
|
|
|
function buildEloMmrFooter(item, payload) {
|
|
const monthLabel = formatMonthKey(payload?.month_key);
|
|
const hoursPlayed = Number(item?.total_time_seconds) / 3600;
|
|
return `${monthLabel || "Mes activo"} · ${formatNumber(item?.valid_matches)} validas / ${formatNumber(item?.total_matches)} totales · ${formatDecimal(hoursPlayed, 1)} h`;
|
|
}
|
|
|
|
function buildMvpComparisonItems(v1Items, v2Items) {
|
|
const v1TopItems = v1Items.slice(0, 3);
|
|
const v2TopItems = v2Items.slice(0, 3);
|
|
const v1Index = new Map(
|
|
v1Items.map((item) => [item?.player?.stable_player_key, item]),
|
|
);
|
|
const v2Index = new Map(
|
|
v2Items.map((item) => [item?.player?.stable_player_key, item]),
|
|
);
|
|
const comparisonKeys = [];
|
|
|
|
[...v1TopItems, ...v2TopItems].forEach((item) => {
|
|
const stableKey = item?.player?.stable_player_key;
|
|
if (!stableKey || comparisonKeys.includes(stableKey)) {
|
|
return;
|
|
}
|
|
comparisonKeys.push(stableKey);
|
|
});
|
|
|
|
return comparisonKeys.map((stableKey) => {
|
|
const v1Item = v1Index.get(stableKey);
|
|
const v2Item = v2Index.get(stableKey);
|
|
const v1Position = Number(v1Item?.ranking_position);
|
|
const v2Position = Number(v2Item?.ranking_position);
|
|
const v1Score = Number(v1Item?.mvp_score);
|
|
const v2Score = Number(v2Item?.mvp_v2_score);
|
|
const scoreDelta = Number.isFinite(v1Score) && Number.isFinite(v2Score)
|
|
? v2Score - v1Score
|
|
: null;
|
|
return {
|
|
playerName:
|
|
v2Item?.player?.name ||
|
|
v1Item?.player?.name ||
|
|
"Jugador no identificado",
|
|
v1PositionLabel: Number.isFinite(v1Position) ? `#${v1Position}` : "Fuera del Top V1",
|
|
v2PositionLabel: Number.isFinite(v2Position) ? `#${v2Position}` : "Fuera del Top V2",
|
|
positionDeltaLabel: buildPositionDeltaLabel(v1Position, v2Position),
|
|
v1ScoreLabel: Number.isFinite(v1Score) ? formatDecimal(v1Score, 1) : "Sin entrada",
|
|
v2ScoreLabel: Number.isFinite(v2Score) ? formatDecimal(v2Score, 1) : "Sin entrada",
|
|
scoreDeltaLabel: buildScoreDeltaLabel(scoreDelta),
|
|
teamkillPenaltyLabel: buildTeamkillPenaltyComparisonLabel(v1Item, v2Item),
|
|
summary: buildMvpComparisonSummary(v1Item, v2Item, scoreDelta),
|
|
};
|
|
});
|
|
}
|
|
|
|
function buildMvpComparisonNote(v1Payload, v2Payload, itemCount) {
|
|
const monthLabel = formatMonthKey(v2Payload?.month_key || v1Payload?.month_key);
|
|
return `${monthLabel || "Periodo mensual activo"}. Comparativa ligera de ${formatNumber(itemCount)} jugadores visibles entre el Top V1 y el Top V2 para validar cambios antes de converger rankings.`;
|
|
}
|
|
|
|
function buildPositionDeltaLabel(v1Position, v2Position) {
|
|
if (Number.isFinite(v1Position) && Number.isFinite(v2Position)) {
|
|
const delta = v1Position - v2Position;
|
|
if (delta > 0) {
|
|
return `Sube ${delta}`;
|
|
}
|
|
if (delta < 0) {
|
|
return `Baja ${Math.abs(delta)}`;
|
|
}
|
|
return "Sin cambio";
|
|
}
|
|
if (Number.isFinite(v2Position)) {
|
|
return "Entra en V2";
|
|
}
|
|
if (Number.isFinite(v1Position)) {
|
|
return "Sale en V2";
|
|
}
|
|
return "Sin cruce";
|
|
}
|
|
|
|
function buildScoreDeltaLabel(scoreDelta) {
|
|
if (!Number.isFinite(scoreDelta)) {
|
|
return "Sin cruce";
|
|
}
|
|
const prefix = scoreDelta > 0 ? "+" : "";
|
|
return `${prefix}${formatDecimal(scoreDelta, 1)}`;
|
|
}
|
|
|
|
function buildTeamkillPenaltyComparisonLabel(v1Item, v2Item) {
|
|
const v1Penalty = Number(v1Item?.teamkill_penalty);
|
|
const v2Penalty = Number(v2Item?.teamkill_penalty_v2);
|
|
if (!Number.isFinite(v1Penalty) && !Number.isFinite(v2Penalty)) {
|
|
return "Sin dato";
|
|
}
|
|
return `${formatDecimal(v1Penalty, 1)} -> ${formatDecimal(v2Penalty, 1)}`;
|
|
}
|
|
|
|
function buildMvpComparisonSummary(v1Item, v2Item, scoreDelta) {
|
|
const deltaLabel = Number.isFinite(scoreDelta)
|
|
? `${scoreDelta > 0 ? "mejora" : scoreDelta < 0 ? "cae" : "mantiene"} ${buildScoreDeltaLabel(scoreDelta)}`
|
|
: "no tiene cruce completo";
|
|
const mostKilled = formatNumber(v2Item?.advanced?.most_killed_count);
|
|
const duelControl = formatNumber(v2Item?.advanced?.duel_control_raw);
|
|
return `En V2 ${deltaLabel}. Senales avanzadas visibles: most killed ${mostKilled} y duel control ${duelControl}.`;
|
|
}
|
|
|
|
function buildSnapshotMetaText(payload, missingMessage) {
|
|
if (!payload?.generated_at) {
|
|
return missingMessage;
|
|
}
|
|
|
|
const parts = [
|
|
payload.is_stale
|
|
? `Actualizado: ${formatTimestamp(payload.generated_at)}`
|
|
: `Actualizado: ${formatTimestamp(payload.generated_at)}`,
|
|
];
|
|
const sourceRangeLabel = buildRangeLabel(
|
|
payload?.source_range_start,
|
|
payload?.source_range_end,
|
|
);
|
|
if (sourceRangeLabel) {
|
|
parts.push(`Cobertura: ${sourceRangeLabel}`);
|
|
}
|
|
return parts.join(" | ");
|
|
}
|
|
|
|
function formatTopMaps(topMaps) {
|
|
if (!Array.isArray(topMaps) || topMaps.length === 0) {
|
|
return "Sin mapas frecuentes";
|
|
}
|
|
|
|
return topMaps
|
|
.map((item) => `${item.map_name} (${formatNumber(item.matches_count)})`)
|
|
.join(" / ");
|
|
}
|
|
|
|
function formatDateOnly(timestamp) {
|
|
if (!timestamp) {
|
|
return "Fecha no disponible";
|
|
}
|
|
|
|
const value = new Date(timestamp);
|
|
if (Number.isNaN(value.getTime())) {
|
|
return "Fecha no disponible";
|
|
}
|
|
|
|
return new Intl.DateTimeFormat("es-ES", {
|
|
dateStyle: "medium",
|
|
}).format(value);
|
|
}
|
|
|
|
function formatMatchResult(result) {
|
|
const winner = result?.winner;
|
|
if (winner === "allies" || winner === "allied") {
|
|
return "Victoria Aliada";
|
|
}
|
|
if (winner === "axis") {
|
|
return "Victoria Axis";
|
|
}
|
|
if (winner === "draw") {
|
|
return "Empate";
|
|
}
|
|
return "Resultado parcial";
|
|
}
|
|
|
|
function formatScore(result) {
|
|
if (!hasMatchScore(result)) {
|
|
return "Resultado no disponible";
|
|
}
|
|
const alliedScore = Number(result.allied_score);
|
|
const axisScore = Number(result.axis_score);
|
|
return `${alliedScore} - ${axisScore}`;
|
|
}
|
|
|
|
function hasMatchScore(result) {
|
|
return (
|
|
Number.isFinite(Number(result?.allied_score)) &&
|
|
Number.isFinite(Number(result?.axis_score))
|
|
);
|
|
}
|
|
|
|
function formatRecentMatchStatus(item) {
|
|
if (hasMatchScore(item?.result)) {
|
|
const sourceLabel = formatResultSource(item?.result_source || item?.source_basis);
|
|
return sourceLabel ? `Resultado confirmado (${sourceLabel})` : "Resultado confirmado";
|
|
}
|
|
if (item?.capture_basis === "rcon-competitive-window") {
|
|
return "En curso";
|
|
}
|
|
if (item?.result_source || item?.source_basis || item?.capture_basis) {
|
|
return formatResultSource(item.result_source || item.source_basis || item.capture_basis);
|
|
}
|
|
return "Resultado no disponible";
|
|
}
|
|
|
|
function formatResultSource(value) {
|
|
if (value === "admin-log-match-ended") {
|
|
return "cierre RCON";
|
|
}
|
|
if (value === "rcon-session") {
|
|
return "sesion RCON";
|
|
}
|
|
if (value === "rcon-materialized-admin-log") {
|
|
return "registro RCON";
|
|
}
|
|
if (value === "public-scoreboard-match") {
|
|
return "scoreboard externo";
|
|
}
|
|
if (value === "rcon-competitive-window") {
|
|
return "ventana RCON";
|
|
}
|
|
return value ? String(value).replaceAll("-", " ") : "";
|
|
}
|
|
|
|
function formatNumber(value) {
|
|
const parsedValue = Number(value);
|
|
if (!Number.isFinite(parsedValue)) {
|
|
return "0";
|
|
}
|
|
|
|
return new Intl.NumberFormat("es-ES").format(parsedValue);
|
|
}
|
|
|
|
function formatDecimal(value, fractionDigits = 1) {
|
|
const parsedValue = Number(value);
|
|
if (!Number.isFinite(parsedValue)) {
|
|
return "0";
|
|
}
|
|
|
|
return new Intl.NumberFormat("es-ES", {
|
|
minimumFractionDigits: fractionDigits,
|
|
maximumFractionDigits: fractionDigits,
|
|
}).format(parsedValue);
|
|
}
|
|
|
|
function formatPercent(value) {
|
|
const parsedValue = Number(value);
|
|
if (!Number.isFinite(parsedValue)) {
|
|
return "0 %";
|
|
}
|
|
|
|
return `${new Intl.NumberFormat("es-ES", {
|
|
maximumFractionDigits: 0,
|
|
}).format(parsedValue * 100)} %`;
|
|
}
|
|
|
|
function formatSignedDecimal(value, fractionDigits = 1) {
|
|
const parsedValue = Number(value);
|
|
if (!Number.isFinite(parsedValue)) {
|
|
return "0";
|
|
}
|
|
const prefix = parsedValue > 0 ? "+" : "";
|
|
return `${prefix}${formatDecimal(parsedValue, fractionDigits)}`;
|
|
}
|
|
|
|
function formatAccuracyMode(mode) {
|
|
if (mode === "exact") {
|
|
return "Exacto";
|
|
}
|
|
if (mode === "approximate") {
|
|
return "Aproximado";
|
|
}
|
|
if (mode === "partial") {
|
|
return "Parcial";
|
|
}
|
|
return "Mixto";
|
|
}
|
|
|
|
function formatMonthKey(monthKey) {
|
|
if (!monthKey) {
|
|
return "";
|
|
}
|
|
|
|
const value = new Date(`${monthKey}-01T00:00:00Z`);
|
|
if (Number.isNaN(value.getTime())) {
|
|
return monthKey;
|
|
}
|
|
|
|
return new Intl.DateTimeFormat("es-ES", {
|
|
month: "long",
|
|
year: "numeric",
|
|
timeZone: "UTC",
|
|
}).format(value);
|
|
}
|
|
|
|
function formatTimestamp(timestamp) {
|
|
if (!timestamp) {
|
|
return "Fecha no disponible";
|
|
}
|
|
|
|
const value = new Date(timestamp);
|
|
if (Number.isNaN(value.getTime())) {
|
|
return "Fecha no disponible";
|
|
}
|
|
|
|
return new Intl.DateTimeFormat("es-ES", {
|
|
dateStyle: "short",
|
|
timeStyle: "short",
|
|
}).format(value);
|
|
}
|
|
|
|
async function getCachedJson(cache, pendingCache, key, url) {
|
|
const cachedPayload = readCachedPayload(cache, key);
|
|
if (cachedPayload) {
|
|
return cachedPayload;
|
|
}
|
|
if (pendingCache.has(key)) {
|
|
return pendingCache.get(key);
|
|
}
|
|
|
|
const request = fetchJson(url)
|
|
.then((payload) => {
|
|
writeCachedPayload(cache, key, payload);
|
|
pendingCache.delete(key);
|
|
return payload;
|
|
})
|
|
.catch((error) => {
|
|
pendingCache.delete(key);
|
|
throw error;
|
|
});
|
|
pendingCache.set(key, request);
|
|
return request;
|
|
}
|
|
|
|
function readCachedPayload(cache, key) {
|
|
const entry = cache.get(key);
|
|
if (!entry) {
|
|
return null;
|
|
}
|
|
|
|
if (entry.expiresAt <= Date.now()) {
|
|
cache.delete(key);
|
|
return null;
|
|
}
|
|
|
|
return entry.payload;
|
|
}
|
|
|
|
function writeCachedPayload(cache, key, payload) {
|
|
cache.set(key, {
|
|
payload,
|
|
expiresAt: Date.now() + resolveSnapshotCacheTtl(payload),
|
|
});
|
|
}
|
|
|
|
function resolveSnapshotCacheTtl(payload) {
|
|
const data = payload?.data;
|
|
if (!data) {
|
|
return NEGATIVE_SNAPSHOT_CACHE_TTL_MS;
|
|
}
|
|
|
|
if (data.snapshot_status === "missing" || data.found === false) {
|
|
return NEGATIVE_SNAPSHOT_CACHE_TTL_MS;
|
|
}
|
|
|
|
if (data.is_stale) {
|
|
return STALE_SNAPSHOT_CACHE_TTL_MS;
|
|
}
|
|
|
|
return SNAPSHOT_CACHE_TTL_MS;
|
|
}
|
|
|
|
async function settlePromise(promise) {
|
|
try {
|
|
const value = await promise;
|
|
return { status: "fulfilled", value };
|
|
} catch (reason) {
|
|
return { status: "rejected", reason };
|
|
}
|
|
}
|
|
|
|
async function fetchJson(url) {
|
|
const response = await fetch(url);
|
|
if (!response.ok) {
|
|
throw new Error(`Request failed with ${response.status}`);
|
|
}
|
|
|
|
return response.json();
|
|
}
|
|
|
|
function escapeHtml(value) {
|
|
return String(value)
|
|
.replaceAll("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """)
|
|
.replaceAll("'", "'");
|
|
}
|
|
|
|
function buildLeaderboardEmptyMessage(metricConfig, serverSlug, timeframeKey) {
|
|
void serverSlug;
|
|
const timeframeLabel = getLeaderboardTimeframeConfig(timeframeKey).shortLabel;
|
|
return metricConfig.emptyMessage.replace("esta ventana", `esta ventana ${timeframeLabel}`);
|
|
}
|
|
|
|
function getHistoricalEmptyState(serverSlug) {
|
|
void serverSlug;
|
|
|
|
return {
|
|
rangeLabel: "Sin cobertura registrada",
|
|
summaryMessage: "Sin datos historicos suficientes",
|
|
summaryNote:
|
|
"Todavia no existe un resumen listo para el alcance seleccionado.",
|
|
recentMessage: "Todavia no hay partidas recientes disponibles.",
|
|
};
|
|
}
|