From f58ba29054d211a43a3e90d42039ded109fa4189 Mon Sep 17 00:00:00 2001 From: devRaGonSa Date: Tue, 24 Mar 2026 10:01:46 +0100 Subject: [PATCH] Add monthly MVP top 3 historical UI --- ai/tasks/done/TASK-069-monthly-mvp-ui-top3.md | 66 ++++++ frontend/assets/css/historico.css | 99 +++++++- frontend/assets/js/historico.js | 217 +++++++++++++++++- frontend/historico.html | 23 +- 4 files changed, 398 insertions(+), 7 deletions(-) create mode 100644 ai/tasks/done/TASK-069-monthly-mvp-ui-top3.md diff --git a/ai/tasks/done/TASK-069-monthly-mvp-ui-top3.md b/ai/tasks/done/TASK-069-monthly-mvp-ui-top3.md new file mode 100644 index 0000000..ec7296d --- /dev/null +++ b/ai/tasks/done/TASK-069-monthly-mvp-ui-top3.md @@ -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. diff --git a/frontend/assets/css/historico.css b/frontend/assets/css/historico.css index 642e20f..28fa988 100644 --- a/frontend/assets/css/historico.css +++ b/frontend/assets/css/historico.css @@ -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%; } diff --git a/frontend/assets/js/historico.js b/frontend/assets/js/historico.js index 0b0865b..e94dc54 100644 --- a/frontend/assets/js/historico.js +++ b/frontend/assets/js/historico.js @@ -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 ` +
+
+
+ #${escapeHtml(item?.ranking_position || "-")} +
+
+

Puntuacion MVP

+ ${escapeHtml( + Number.isFinite(scoreValue) ? scoreValue.toFixed(1) : "0.0", + )} +
+
+
+ ${escapeHtml( + item?.player?.name || "Jugador no identificado", + )} +
+
+
+ Kills + ${escapeHtml(formatNumber(item?.totals?.kills))} +
+
+ Soporte + ${escapeHtml(formatNumber(item?.totals?.support))} +
+
+ KPM + ${escapeHtml(formatDecimal(item?.derived?.kpm, 2))} +
+
+ KDA + ${escapeHtml(formatDecimal(item?.derived?.kda, 2))} +
+
+ +
+ `; +} + 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"; diff --git a/frontend/historico.html b/frontend/historico.html index ea5afe8..d7c6b0c 100644 --- a/frontend/historico.html +++ b/frontend/historico.html @@ -81,7 +81,7 @@
-
+ +
+
+
+
+

MVP mensual

+

Top 3 del alcance activo

+

+ Cargando periodo del MVP mensual... +

+

+ Cargando snapshot del MVP mensual... +

+
+
+

+ Cargando Top 3 MVP mensual... +

+
+
+
+