Add monthly MVP top 3 historical UI

This commit is contained in:
devRaGonSa
2026-03-24 10:01:46 +01:00
parent d7bb099223
commit f58ba29054
4 changed files with 398 additions and 7 deletions

View File

@@ -152,8 +152,15 @@
gap: 14px;
}
.historical-mvp-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr));
gap: 14px;
}
.historical-stat-card,
.historical-match-card {
.historical-match-card,
.historical-mvp-card {
position: relative;
overflow: hidden;
padding: 18px;
@@ -167,7 +174,10 @@
.historical-stat-card p,
.historical-stat-card strong,
.historical-match-card p,
.historical-match-card strong {
.historical-match-card strong,
.historical-mvp-card p,
.historical-mvp-card strong,
.historical-mvp-card span {
margin: 0;
}
@@ -185,6 +195,83 @@
line-height: 1.4;
}
.historical-mvp-card {
display: grid;
gap: 16px;
}
.historical-mvp-card--rank-1 {
border-color: rgba(210, 182, 118, 0.34);
background:
radial-gradient(circle at top right, rgba(210, 182, 118, 0.16), transparent 42%),
linear-gradient(180deg, rgba(36, 32, 20, 0.96), rgba(12, 15, 11, 0.98));
}
.historical-mvp-card__top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.historical-mvp-card__rank {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 42px;
min-height: 42px;
padding: 0 12px;
border: 1px solid rgba(210, 182, 118, 0.26);
border-radius: 999px;
color: var(--accent-warm);
font-size: 0.76rem;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.historical-mvp-card__player {
font-size: 1.18rem;
line-height: 1.3;
}
.historical-mvp-card__score-label {
margin-bottom: 6px;
color: var(--muted);
font-size: 0.72rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.historical-mvp-card__score-value {
color: var(--accent-strong);
font-size: 1.8rem;
line-height: 1;
}
.historical-mvp-card__meta {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.historical-mvp-card__meta span {
display: block;
margin-bottom: 6px;
color: var(--muted);
font-size: 0.7rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.historical-mvp-card__footer {
padding-top: 12px;
border-top: 1px solid rgba(159, 168, 141, 0.12);
color: var(--text-soft);
font-size: 0.88rem;
line-height: 1.5;
}
.historical-state {
margin: 0 0 16px;
padding: 14px 16px;
@@ -306,6 +393,14 @@
flex-direction: column;
}
.historical-mvp-card__top {
flex-direction: column;
}
.historical-mvp-card__meta {
grid-template-columns: 1fr;
}
.historical-tab {
width: 100%;
}

View File

@@ -93,6 +93,13 @@ document.addEventListener("DOMContentLoaded", () => {
const weeklyStateNode = document.getElementById("weekly-leaderboard-state");
const weeklyTableNode = document.getElementById("weekly-leaderboard-table");
const weeklyBodyNode = document.getElementById("weekly-leaderboard-body");
const monthlyMvpTitleNode = document.getElementById("monthly-mvp-title");
const monthlyMvpStateNode = document.getElementById("monthly-mvp-state");
const monthlyMvpListNode = document.getElementById("monthly-mvp-list");
const monthlyMvpNoteNode = document.getElementById("monthly-mvp-note");
const monthlyMvpSnapshotMetaNode = document.getElementById(
"monthly-mvp-snapshot-meta",
);
const weeklyValueHeadingNode = document.getElementById("weekly-leaderboard-value-heading");
const weeklyWindowNoteNode = document.getElementById("weekly-window-note");
const weeklySnapshotMetaNode = document.getElementById(
@@ -115,6 +122,7 @@ document.addEventListener("DOMContentLoaded", () => {
const summaryCache = new Map();
const recentMatchesCache = new Map();
const leaderboardCache = new Map();
const monthlyMvpCache = new Map();
const pendingRequestCache = new Map();
const getSummarySnapshot = (serverSlug) =>
@@ -141,6 +149,14 @@ document.addEventListener("DOMContentLoaded", () => {
`${backendBaseUrl}/api/historical/snapshots/leaderboard?server=${encodeURIComponent(serverSlug)}&timeframe=${encodeURIComponent(timeframeKey)}&metric=${encodeURIComponent(metricKey)}&limit=10`,
);
const getMonthlyMvpSnapshot = (serverSlug) =>
getCachedJson(
monthlyMvpCache,
pendingRequestCache,
buildMonthlyMvpSnapshotKey(serverSlug),
`${backendBaseUrl}/api/historical/snapshots/monthly-mvp?server=${encodeURIComponent(serverSlug)}&limit=3`,
);
const refreshServerContent = async () => {
const requestId = activeServerRequestId + 1;
const leaderboardRequestId = activeLeaderboardRequestId + 1;
@@ -168,6 +184,14 @@ document.addEventListener("DOMContentLoaded", () => {
summaryNoteNode.textContent = `La vista esta leyendo snapshots precalculados del historico local para ${activeServerLabel}.`;
setSnapshotMeta(summarySnapshotMetaNode, "Cargando snapshot de resumen...");
renderSummaryLoading(summaryNode);
monthlyMvpTitleNode.textContent = `Top 3 MVP mensual - ${activeServerLabel}`;
monthlyMvpNoteNode.textContent = "Cargando periodo del MVP mensual...";
setState(monthlyMvpStateNode, "Cargando Top 3 MVP mensual...");
monthlyMvpListNode.innerHTML = "";
setSnapshotMeta(
monthlyMvpSnapshotMetaNode,
"Cargando snapshot del MVP mensual...",
);
weeklyWindowNoteNode.textContent = "Cargando snapshot del ranking activo...";
setSnapshotMeta(
weeklySnapshotMetaNode,
@@ -192,6 +216,21 @@ document.addEventListener("DOMContentLoaded", () => {
);
}
const cachedMonthlyMvpPayload = readCachedPayload(
monthlyMvpCache,
buildMonthlyMvpSnapshotKey(activeServerSlug),
);
if (cachedMonthlyMvpPayload) {
hydrateMonthlyMvp(
{ status: "fulfilled", value: cachedMonthlyMvpPayload },
monthlyMvpStateNode,
monthlyMvpListNode,
monthlyMvpTitleNode,
monthlyMvpNoteNode,
monthlyMvpSnapshotMetaNode,
);
}
const cachedLeaderboardPayload = readCachedPayload(
leaderboardCache,
buildLeaderboardSnapshotKey(
@@ -306,6 +345,28 @@ document.addEventListener("DOMContentLoaded", () => {
targetTimeframe,
);
});
void settlePromise(getMonthlyMvpSnapshot(targetServerSlug)).then((monthlyMvpResult) => {
if (
!isActiveServerRequest(
requestId,
targetServerSlug,
targetTimeframe,
targetMetric,
)
) {
return;
}
hydrateMonthlyMvp(
monthlyMvpResult,
monthlyMvpStateNode,
monthlyMvpListNode,
monthlyMvpTitleNode,
monthlyMvpNoteNode,
monthlyMvpSnapshotMetaNode,
);
});
};
const refreshLeaderboardContent = async () => {
@@ -559,7 +620,7 @@ function hydrateWeeklyLeaderboard(
resolvedTimeframeKey,
);
noteNode.textContent =
"No se pudo leer el snapshot precalculado para esta metrica.";
"No se pudo leer los datos precalculados para esta metrica.";
setSnapshotMeta(snapshotMetaNode, "Error al leer el snapshot del ranking.");
setState(
stateNode,
@@ -652,6 +713,56 @@ function hydrateRecentMatches(result, stateNode, listNode, snapshotMetaNode) {
stateNode.hidden = true;
}
function hydrateMonthlyMvp(
result,
stateNode,
listNode,
titleNode,
noteNode,
snapshotMetaNode,
) {
if (result.status !== "fulfilled") {
titleNode.textContent = `Top 3 MVP mensual - ${getHistoricalServerLabel(activeServerSlug)}`;
noteNode.textContent = "No se pudo leer el snapshot mensual del MVP.";
setSnapshotMeta(snapshotMetaNode, "Error al leer el snapshot del MVP mensual.");
setState(stateNode, "No se pudo cargar el Top 3 MVP mensual.", true);
listNode.innerHTML = "";
return;
}
const payload = result.value?.data;
titleNode.textContent = `Top 3 MVP mensual - ${getHistoricalServerLabel(
payload?.server_slug || activeServerSlug,
)}`;
noteNode.textContent = buildMonthlyMvpNote(payload);
setSnapshotMeta(
snapshotMetaNode,
buildSnapshotMetaText(payload, "Snapshot del MVP mensual pendiente de generacion."),
);
if (!payload?.found) {
setState(
stateNode,
"Todavia no hay un Top 3 MVP mensual 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 renderRecentMatchCard(item) {
const mapName = item.map?.pretty_name || item.map?.name || "Mapa no disponible";
return `
@@ -706,6 +817,51 @@ function renderSummaryCard(label, value) {
`;
}
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 setState(node, message, isError = false) {
node.textContent = message;
node.hidden = false;
@@ -807,6 +963,10 @@ function buildLeaderboardSnapshotKey(serverSlug, timeframeKey, metricKey) {
return `leaderboard:${serverSlug}:${timeframeKey}:${metricKey}`;
}
function buildMonthlyMvpSnapshotKey(serverSlug) {
return `monthly-mvp:${serverSlug}`;
}
function buildRangeLabel(start, end) {
if (!start && !end) {
return "";
@@ -848,7 +1008,7 @@ function buildCoveragePeriodLabel(coverage, timeRange, serverSlug) {
function buildSummaryNote(summaryBasis, weeklyWindowDays, coverage, serverSlug) {
const basisLabel =
summaryBasis === "snapshot-precomputed"
? "el snapshot precalculado del historico local"
? "el historico local"
: "el historico persistido disponible";
const status = coverage?.status;
void weeklyWindowDays;
@@ -857,7 +1017,7 @@ function buildSummaryNote(summaryBasis, weeklyWindowDays, coverage, serverSlug)
return `Este bloque resume ${basisLabel}. La cobertura registrada todavia es inicial y puede crecer en los proximos dias.`;
}
if (serverSlug === "all-servers") {
return `Resumen global servido desde ${basisLabel} y combinado solo con los servidores que ya tienen historico disponible.`;
return `Resumen de los servidore desde ${basisLabel} y combinado solo los servidores actuales de la comunidad.`;
}
return `Resumen servido desde ${basisLabel}.`;
}
@@ -867,7 +1027,7 @@ function buildWeeklyWindowNote(payload) {
const timeframeLabel = getLeaderboardTimeframeConfig(
payload?.timeframe || activeLeaderboardTimeframe,
).shortLabel;
return `No existe un snapshot ${timeframeLabel} listo para esta metrica en el alcance activo.`;
return `No existen datos en ${timeframeLabel} suficientes para esta metrica en el rango activo.`;
}
const start = formatTimestamp(payload?.window_start);
@@ -899,6 +1059,26 @@ function buildRecentMatchesNote(serverSlug) {
return `Lista de cierres ya registrados para ${getHistoricalServerLabel(serverSlug)}.`;
}
function buildMonthlyMvpNote(payload) {
if (!payload?.found) {
return "El Top 3 mensual aparecera cuando exista un snapshot 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 de elegibilidad.`;
}
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 buildSnapshotMetaText(payload, missingMessage) {
if (!payload?.generated_at) {
return missingMessage;
@@ -977,6 +1157,35 @@ function formatNumber(value) {
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 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";