From 1efeecfd829f7559c4e6d6ab912021210db482c5 Mon Sep 17 00:00:00 2001 From: devRaGonSa Date: Sat, 21 Mar 2026 00:08:10 +0100 Subject: [PATCH] Add historical leaderboard tabs UI --- ...ASK-038-historical-leaderboards-tabs-ui.md | 72 +++++++++++ frontend/assets/css/historico.css | 43 +++++++ frontend/assets/js/historico.js | 117 +++++++++++++++--- frontend/historico.html | 54 +++++++- 4 files changed, 266 insertions(+), 20 deletions(-) create mode 100644 ai/tasks/done/TASK-038-historical-leaderboards-tabs-ui.md diff --git a/ai/tasks/done/TASK-038-historical-leaderboards-tabs-ui.md b/ai/tasks/done/TASK-038-historical-leaderboards-tabs-ui.md new file mode 100644 index 0000000..18741da --- /dev/null +++ b/ai/tasks/done/TASK-038-historical-leaderboards-tabs-ui.md @@ -0,0 +1,72 @@ +# TASK-038-historical-leaderboards-tabs-ui + +## Goal +Transformar la sección de ranking semanal de la página histórica en un bloque con pestañas, permitiendo alternar entre varios leaderboards semanales del servidor seleccionado. + +## Context +La UI histórica actual muestra un único ranking semanal de kills. El objetivo ahora es convertir esa zona en una sección con pestañas para poder consultar varias métricas sin recargar la página y manteniendo una experiencia clara. Las pestañas requeridas inicialmente son: +- Top kills +- Top muertes +- Top número de partidas con más de 100 kills +- Top puntos de soporte + +La UI debe seguir siendo propia del proyecto y consumir únicamente la API histórica interna. + +## Steps +1. Revisar la página histórica actual y el bloque de ranking semanal. +2. Revisar la nueva API histórica multitétrica disponible tras la task previa. +3. Diseñar un sistema de pestañas claro y coherente con el estilo de la web. +4. Implementar las pestañas para: + - Top kills + - Top muertes + - Top número de partidas con más de 100 kills + - Top puntos de soporte +5. Hacer que las pestañas reutilicen el servidor actualmente seleccionado. +6. Mantener estados de: + - loading + - vacío + - error +7. Ajustar títulos, subtítulos y labels de la sección para que sean claros y no sobrecarguen visualmente. +8. No usar páginas externas ni incrustaciones del scoreboard de la comunidad. +9. Mantener la coherencia visual con el resto de la página histórica y con la landing principal. +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 + +## Expected Files to Modify +- frontend/historico.html +- frontend/assets/js/historico.js +- frontend/assets/css/historico.css +- opcionalmente documentación mínima si hay un cambio visible relevante en la navegación histórica + +## Constraints +- No crear nuevas páginas basadas en URLs externas. +- No romper la UI histórica actual fuera del bloque de ranking. +- No introducir frameworks nuevos. +- No hacer cambios destructivos. +- Mantener la solución centrada en pestañas, claridad y consumo de nuestra API. + +## Validation +- La sección de ranking semanal se convierte en un bloque con pestañas funcionales. +- Las pestañas muestran: + - Top kills + - Top muertes + - Top partidas con más de 100 kills + - Top puntos de soporte +- La UI consume únicamente la API interna del proyecto. +- El selector de servidor sigue funcionando con estas pestañas. +- Los cambios quedan committeados y se hace push al remoto 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 1a1aef1..a2bdfb6 100644 --- a/frontend/assets/css/historico.css +++ b/frontend/assets/css/historico.css @@ -100,6 +100,41 @@ color: var(--text); } +.historical-tabs { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin: 0 0 16px; +} + +.historical-tab { + min-height: 42px; + padding: 0 16px; + border: 1px solid rgba(159, 168, 141, 0.2); + border-radius: 999px; + background: rgba(13, 17, 12, 0.62); + color: var(--muted); + font: inherit; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + cursor: pointer; + transition: + transform 160ms ease, + border-color 160ms ease, + background 160ms ease, + color 160ms ease; +} + +.historical-tab:hover, +.historical-tab:focus-visible, +.historical-tab.is-active { + transform: translateY(-1px); + border-color: rgba(210, 182, 118, 0.46); + background: linear-gradient(180deg, rgba(183, 201, 125, 0.16), rgba(89, 101, 58, 0.18)); + color: var(--text); +} + .historical-summary-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr)); @@ -256,6 +291,14 @@ flex-direction: column; } + .historical-tabs { + flex-direction: column; + } + + .historical-tab { + width: 100%; + } + .historical-selector__button { width: 100%; } diff --git a/frontend/assets/js/historico.js b/frontend/assets/js/historico.js index fe2def2..c171c5d 100644 --- a/frontend/assets/js/historico.js +++ b/frontend/assets/js/historico.js @@ -3,6 +3,33 @@ const HISTORICAL_SERVER_SLUGS = Object.freeze([ "comunidad-hispana-02", ]); const DEFAULT_HISTORICAL_SERVER = HISTORICAL_SERVER_SLUGS[0]; +const LEADERBOARD_METRICS = Object.freeze([ + { + key: "kills", + title: "Top kills de los ultimos 7 dias", + valueHeading: "Kills", + emptyMessage: "Sin datos historicos suficientes para mostrar el top de kills semanal.", + }, + { + key: "deaths", + title: "Top muertes de los ultimos 7 dias", + valueHeading: "Muertes", + emptyMessage: "Sin datos historicos suficientes para mostrar el top de muertes semanal.", + }, + { + 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 semanal.", + }, + { + key: "support", + title: "Top puntos de soporte de los ultimos 7 dias", + valueHeading: "Soporte", + emptyMessage: "Sin datos historicos suficientes para mostrar el top de soporte semanal.", + }, +]); +const DEFAULT_LEADERBOARD_METRIC = LEADERBOARD_METRICS[0].key; document.addEventListener("DOMContentLoaded", () => { const backendBaseUrl = @@ -10,51 +37,72 @@ document.addEventListener("DOMContentLoaded", () => { const selectorButtons = Array.from( document.querySelectorAll("[data-server-slug]"), ); + 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 weeklyStateNode = document.getElementById("weekly-top-kills-state"); - const weeklyTableNode = document.getElementById("weekly-top-kills-table"); - const weeklyBodyNode = document.getElementById("weekly-top-kills-body"); + 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 recentStateNode = document.getElementById("recent-matches-state"); const recentListNode = document.getElementById("recent-matches-list"); const params = new URLSearchParams(window.location.search); let activeServerSlug = normalizeServerSlug(params.get("server")); + let activeLeaderboardMetric = normalizeLeaderboardMetric(params.get("metric")); + let refreshRequestId = 0; const refreshHistoricalView = async () => { + const requestId = refreshRequestId + 1; + refreshRequestId = requestId; + const activeMetricConfig = getLeaderboardMetricConfig(activeLeaderboardMetric); + syncActiveButtons(selectorButtons, activeServerSlug); + syncLeaderboardTabs(leaderboardTabButtons, activeLeaderboardMetric); setRangeBadge(rangeNode, "Cargando rango temporal", false); summaryNoteNode.textContent = "Este bloque resume solo la cobertura ya registrada en la base local."; renderSummaryLoading(summaryNode); + weeklyTitleNode.textContent = activeMetricConfig.title; + weeklyValueHeadingNode.textContent = activeMetricConfig.valueHeading; weeklyWindowNoteNode.textContent = "Cargando ventana semanal..."; setState(weeklyStateNode, "Cargando ranking semanal..."); setState(recentStateNode, "Cargando partidas recientes..."); weeklyTableNode.hidden = true; recentListNode.innerHTML = ""; - const [summaryResult, topKillsResult, recentMatchesResult] = + const [summaryResult, leaderboardResult, recentMatchesResult] = await Promise.allSettled([ fetchJson( `${backendBaseUrl}/api/historical/server-summary?server=${encodeURIComponent(activeServerSlug)}`, ), fetchJson( - `${backendBaseUrl}/api/historical/weekly-top-kills?server=${encodeURIComponent(activeServerSlug)}&limit=10`, + `${backendBaseUrl}/api/historical/weekly-leaderboard?server=${encodeURIComponent(activeServerSlug)}&metric=${encodeURIComponent(activeLeaderboardMetric)}&limit=10`, ), fetchJson( `${backendBaseUrl}/api/historical/recent-matches?server=${encodeURIComponent(activeServerSlug)}&limit=6`, ), ]); + if (requestId !== refreshRequestId) { + return; + } + hydrateSummary(summaryResult, summaryNode, rangeNode, summaryNoteNode); - hydrateWeeklyTopKills( - topKillsResult, + hydrateWeeklyLeaderboard( + leaderboardResult, weeklyStateNode, weeklyTableNode, weeklyBodyNode, + weeklyTitleNode, + weeklyValueHeadingNode, weeklyWindowNoteNode, + activeMetricConfig, ); hydrateRecentMatches(recentMatchesResult, recentStateNode, recentListNode); }; @@ -68,6 +116,22 @@ document.addEventListener("DOMContentLoaded", () => { activeServerSlug = nextServerSlug; params.set("server", activeServerSlug); + params.set("metric", activeLeaderboardMetric); + window.history.replaceState({}, "", `?${params.toString()}`); + void refreshHistoricalView(); + }); + }); + + leaderboardTabButtons.forEach((button) => { + button.addEventListener("click", () => { + const nextMetric = normalizeLeaderboardMetric(button.dataset.leaderboardMetric); + if (nextMetric === activeLeaderboardMetric) { + return; + } + + activeLeaderboardMetric = nextMetric; + params.set("server", activeServerSlug); + params.set("metric", activeLeaderboardMetric); window.history.replaceState({}, "", `?${params.toString()}`); void refreshHistoricalView(); }); @@ -129,13 +193,18 @@ function hydrateSummary(result, summaryNode, rangeNode, noteNode) { ].join(""); } -function hydrateWeeklyTopKills( +function hydrateWeeklyLeaderboard( result, stateNode, tableNode, bodyNode, + titleNode, + valueHeadingNode, noteNode, + metricConfig, ) { + titleNode.textContent = metricConfig.title; + valueHeadingNode.textContent = metricConfig.valueHeading; if (result.status !== "fulfilled") { noteNode.textContent = "El ranking usa solo partidas cerradas dentro de la ultima ventana semanal."; @@ -148,10 +217,7 @@ function hydrateWeeklyTopKills( noteNode.textContent = buildWeeklyWindowNote(payload); const items = payload?.items; if (!Array.isArray(items) || items.length === 0) { - setState( - stateNode, - "Sin datos historicos suficientes para construir el ranking semanal.", - ); + setState(stateNode, metricConfig.emptyMessage); tableNode.hidden = true; return; } @@ -162,9 +228,8 @@ function hydrateWeeklyTopKills( #${escapeHtml(item.ranking_position)} ${escapeHtml(item.player?.name || "Jugador no identificado")} - ${escapeHtml(formatNumber(item.weekly_kills))} + ${escapeHtml(formatNumber(item.metric_value))} ${escapeHtml(formatNumber(item.matches_considered))} - ${escapeHtml(item.server?.name || "Servidor no disponible")} `, ) @@ -264,6 +329,14 @@ function syncActiveButtons(buttons, 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 normalizeServerSlug(rawValue) { const normalized = typeof rawValue === "string" ? rawValue.trim() : ""; if (HISTORICAL_SERVER_SLUGS.includes(normalized)) { @@ -273,6 +346,22 @@ function normalizeServerSlug(rawValue) { return DEFAULT_HISTORICAL_SERVER; } +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 getLeaderboardMetricConfig(metricKey) { + return ( + LEADERBOARD_METRICS.find((metric) => metric.key === metricKey) || + LEADERBOARD_METRICS[0] + ); +} + function buildRangeLabel(start, end) { if (!start && !end) { return ""; diff --git a/frontend/historico.html b/frontend/historico.html index 0bb3c03..f48472e 100644 --- a/frontend/historico.html +++ b/frontend/historico.html @@ -94,27 +94,69 @@

Ranking semanal

-

Top kills de los ultimos 7 dias

+

Ranking semanal del servidor activo

Cargando ventana semanal...

-

+

+ + + + +
+

Cargando ranking semanal...

- + - + - - +