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

@@ -0,0 +1,66 @@
# TASK-069-monthly-mvp-ui-top3
## Goal
Añadir a la página histórica una sección clara y visual para mostrar el Top 3 MVP mensual V1, consumiendo los snapshots/API ya preparados y manteniendo coherencia con la navegación existente.
## Context
El monthly MVP V1 ya está definido a nivel de diseño y, tras las tasks previas, debe quedar disponible en snapshots y API. El siguiente paso es exponerlo en la UI de forma clara, sin convertir la pantalla en algo recargado ni confuso.
## Steps
1. Revisar la UI histórica actual y el nuevo endpoint/snapshot del monthly MVP.
2. Diseñar una sección visual clara para el Top 3 MVP mensual.
3. Mantener compatibilidad con el selector actual de ámbito:
- servidor individual
- all-servers
4. Mostrar, como mínimo:
- posición 1, 2 y 3
- nombre de jugador
- puntuación MVP
- metadatos mínimos útiles según el diseño aprobado
5. Mostrar el periodo mensual real usado de forma natural.
6. Mantener estados de:
- loading
- empty
- error
7. Integrar la nueva sección sin romper la navegación actual semanal/mensual de leaderboards por métrica.
8. Mantener el rendimiento percibido y la coherencia visual con la página actual.
9. No crear páginas nuevas.
10. Al completar la implementación:
- dejar el repositorio consistente
- hacer commit
- hacer push al remoto si el entorno lo permite
## Files to Read First
- AGENTS.md
- frontend/historico.html
- frontend/assets/js/historico.js
- frontend/assets/css/historico.css
- backend/app/routes.py
- backend/app/payloads.py
- backend/README.md
- docs/monthly-mvp-ranking-scoring-design.md
## Expected Files to Modify
- frontend/historico.html
- frontend/assets/js/historico.js
- frontend/assets/css/historico.css
- opcionalmente backend docs mínimas si hay que reflejar el nuevo bloque visible
## Constraints
- No romper la UI histórica actual.
- No introducir frameworks nuevos.
- No crear páginas nuevas.
- No hacer cambios destructivos.
- Mantener el trabajo centrado en una sección Top 3 mensual clara y útil.
## Validation
- La UI histórica muestra el Top 3 MVP mensual.
- Funciona para servidor individual y all-servers.
- El periodo mensual mostrado es claro.
- Los estados de loading/empty/error son coherentes.
- La pantalla sigue siendo usable y visualmente consistente.
- Los cambios quedan committeados y se hace push si el entorno lo permite.
## Change Budget
- Preferir menos de 5 archivos modificados.
- Preferir menos de 220 líneas cambiadas.

View File

@@ -152,8 +152,15 @@
gap: 14px; gap: 14px;
} }
.historical-mvp-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr));
gap: 14px;
}
.historical-stat-card, .historical-stat-card,
.historical-match-card { .historical-match-card,
.historical-mvp-card {
position: relative; position: relative;
overflow: hidden; overflow: hidden;
padding: 18px; padding: 18px;
@@ -167,7 +174,10 @@
.historical-stat-card p, .historical-stat-card p,
.historical-stat-card strong, .historical-stat-card strong,
.historical-match-card p, .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; margin: 0;
} }
@@ -185,6 +195,83 @@
line-height: 1.4; 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 { .historical-state {
margin: 0 0 16px; margin: 0 0 16px;
padding: 14px 16px; padding: 14px 16px;
@@ -306,6 +393,14 @@
flex-direction: column; flex-direction: column;
} }
.historical-mvp-card__top {
flex-direction: column;
}
.historical-mvp-card__meta {
grid-template-columns: 1fr;
}
.historical-tab { .historical-tab {
width: 100%; width: 100%;
} }

View File

@@ -93,6 +93,13 @@ document.addEventListener("DOMContentLoaded", () => {
const weeklyStateNode = document.getElementById("weekly-leaderboard-state"); const weeklyStateNode = document.getElementById("weekly-leaderboard-state");
const weeklyTableNode = document.getElementById("weekly-leaderboard-table"); const weeklyTableNode = document.getElementById("weekly-leaderboard-table");
const weeklyBodyNode = document.getElementById("weekly-leaderboard-body"); 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 weeklyValueHeadingNode = document.getElementById("weekly-leaderboard-value-heading");
const weeklyWindowNoteNode = document.getElementById("weekly-window-note"); const weeklyWindowNoteNode = document.getElementById("weekly-window-note");
const weeklySnapshotMetaNode = document.getElementById( const weeklySnapshotMetaNode = document.getElementById(
@@ -115,6 +122,7 @@ document.addEventListener("DOMContentLoaded", () => {
const summaryCache = new Map(); const summaryCache = new Map();
const recentMatchesCache = new Map(); const recentMatchesCache = new Map();
const leaderboardCache = new Map(); const leaderboardCache = new Map();
const monthlyMvpCache = new Map();
const pendingRequestCache = new Map(); const pendingRequestCache = new Map();
const getSummarySnapshot = (serverSlug) => 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`, `${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 refreshServerContent = async () => {
const requestId = activeServerRequestId + 1; const requestId = activeServerRequestId + 1;
const leaderboardRequestId = activeLeaderboardRequestId + 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}.`; summaryNoteNode.textContent = `La vista esta leyendo snapshots precalculados del historico local para ${activeServerLabel}.`;
setSnapshotMeta(summarySnapshotMetaNode, "Cargando snapshot de resumen..."); setSnapshotMeta(summarySnapshotMetaNode, "Cargando snapshot de resumen...");
renderSummaryLoading(summaryNode); 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..."; weeklyWindowNoteNode.textContent = "Cargando snapshot del ranking activo...";
setSnapshotMeta( setSnapshotMeta(
weeklySnapshotMetaNode, 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( const cachedLeaderboardPayload = readCachedPayload(
leaderboardCache, leaderboardCache,
buildLeaderboardSnapshotKey( buildLeaderboardSnapshotKey(
@@ -306,6 +345,28 @@ document.addEventListener("DOMContentLoaded", () => {
targetTimeframe, targetTimeframe,
); );
}); });
void settlePromise(getMonthlyMvpSnapshot(targetServerSlug)).then((monthlyMvpResult) => {
if (
!isActiveServerRequest(
requestId,
targetServerSlug,
targetTimeframe,
targetMetric,
)
) {
return;
}
hydrateMonthlyMvp(
monthlyMvpResult,
monthlyMvpStateNode,
monthlyMvpListNode,
monthlyMvpTitleNode,
monthlyMvpNoteNode,
monthlyMvpSnapshotMetaNode,
);
});
}; };
const refreshLeaderboardContent = async () => { const refreshLeaderboardContent = async () => {
@@ -559,7 +620,7 @@ function hydrateWeeklyLeaderboard(
resolvedTimeframeKey, resolvedTimeframeKey,
); );
noteNode.textContent = 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."); setSnapshotMeta(snapshotMetaNode, "Error al leer el snapshot del ranking.");
setState( setState(
stateNode, stateNode,
@@ -652,6 +713,56 @@ function hydrateRecentMatches(result, stateNode, listNode, snapshotMetaNode) {
stateNode.hidden = true; 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) { function renderRecentMatchCard(item) {
const mapName = item.map?.pretty_name || item.map?.name || "Mapa no disponible"; const mapName = item.map?.pretty_name || item.map?.name || "Mapa no disponible";
return ` 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) { function setState(node, message, isError = false) {
node.textContent = message; node.textContent = message;
node.hidden = false; node.hidden = false;
@@ -807,6 +963,10 @@ function buildLeaderboardSnapshotKey(serverSlug, timeframeKey, metricKey) {
return `leaderboard:${serverSlug}:${timeframeKey}:${metricKey}`; return `leaderboard:${serverSlug}:${timeframeKey}:${metricKey}`;
} }
function buildMonthlyMvpSnapshotKey(serverSlug) {
return `monthly-mvp:${serverSlug}`;
}
function buildRangeLabel(start, end) { function buildRangeLabel(start, end) {
if (!start && !end) { if (!start && !end) {
return ""; return "";
@@ -848,7 +1008,7 @@ function buildCoveragePeriodLabel(coverage, timeRange, serverSlug) {
function buildSummaryNote(summaryBasis, weeklyWindowDays, coverage, serverSlug) { function buildSummaryNote(summaryBasis, weeklyWindowDays, coverage, serverSlug) {
const basisLabel = const basisLabel =
summaryBasis === "snapshot-precomputed" summaryBasis === "snapshot-precomputed"
? "el snapshot precalculado del historico local" ? "el historico local"
: "el historico persistido disponible"; : "el historico persistido disponible";
const status = coverage?.status; const status = coverage?.status;
void weeklyWindowDays; 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.`; return `Este bloque resume ${basisLabel}. La cobertura registrada todavia es inicial y puede crecer en los proximos dias.`;
} }
if (serverSlug === "all-servers") { 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}.`; return `Resumen servido desde ${basisLabel}.`;
} }
@@ -867,7 +1027,7 @@ function buildWeeklyWindowNote(payload) {
const timeframeLabel = getLeaderboardTimeframeConfig( const timeframeLabel = getLeaderboardTimeframeConfig(
payload?.timeframe || activeLeaderboardTimeframe, payload?.timeframe || activeLeaderboardTimeframe,
).shortLabel; ).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); const start = formatTimestamp(payload?.window_start);
@@ -899,6 +1059,26 @@ function buildRecentMatchesNote(serverSlug) {
return `Lista de cierres ya registrados para ${getHistoricalServerLabel(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) { function buildSnapshotMetaText(payload, missingMessage) {
if (!payload?.generated_at) { if (!payload?.generated_at) {
return missingMessage; return missingMessage;
@@ -977,6 +1157,35 @@ function formatNumber(value) {
return new Intl.NumberFormat("es-ES").format(parsedValue); 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) { function formatTimestamp(timestamp) {
if (!timestamp) { if (!timestamp) {
return "Fecha no disponible"; return "Fecha no disponible";

View File

@@ -81,7 +81,7 @@
</header> </header>
<main class="content historical-content"> <main class="content historical-content">
<section class="panel historical-panel" aria-labelledby="historical-summary-title"> <section class="panel historical-panel" aria-labelledby="historical-summary-title" hidden>
<div class="panel__shell"> <div class="panel__shell">
<div class="panel__header historical-panel__header"> <div class="panel__header historical-panel__header">
<div> <div>
@@ -107,6 +107,27 @@
</div> </div>
</section> </section>
<section class="panel historical-panel" aria-labelledby="monthly-mvp-title">
<div class="panel__shell">
<div class="panel__header historical-panel__header">
<div>
<p class="eyebrow eyebrow--section">MVP mensual</p>
<h2 id="monthly-mvp-title">Top 3 del alcance activo</h2>
<p class="historical-panel__note" id="monthly-mvp-note">
Cargando periodo del MVP mensual...
</p>
<p class="historical-snapshot-meta" id="monthly-mvp-snapshot-meta">
Cargando snapshot del MVP mensual...
</p>
</div>
</div>
<p class="historical-state" id="monthly-mvp-state" aria-live="polite">
Cargando Top 3 MVP mensual...
</p>
<div class="historical-mvp-grid" id="monthly-mvp-list"></div>
</div>
</section>
<section class="panel historical-panel" aria-labelledby="weekly-ranking-title"> <section class="panel historical-panel" aria-labelledby="weekly-ranking-title">
<div class="panel__shell"> <div class="panel__shell">
<div class="panel__header historical-panel__header"> <div class="panel__header historical-panel__header">