Add historical leaderboard tabs UI

This commit is contained in:
devRaGonSa
2026-03-21 00:08:10 +01:00
parent 8a888f4cff
commit 1efeecfd82
4 changed files with 266 additions and 20 deletions

View File

@@ -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.

View File

@@ -100,6 +100,41 @@
color: var(--text); 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 { .historical-summary-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr)); grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr));
@@ -256,6 +291,14 @@
flex-direction: column; flex-direction: column;
} }
.historical-tabs {
flex-direction: column;
}
.historical-tab {
width: 100%;
}
.historical-selector__button { .historical-selector__button {
width: 100%; width: 100%;
} }

View File

@@ -3,6 +3,33 @@ const HISTORICAL_SERVER_SLUGS = Object.freeze([
"comunidad-hispana-02", "comunidad-hispana-02",
]); ]);
const DEFAULT_HISTORICAL_SERVER = HISTORICAL_SERVER_SLUGS[0]; 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", () => { document.addEventListener("DOMContentLoaded", () => {
const backendBaseUrl = const backendBaseUrl =
@@ -10,51 +37,72 @@ document.addEventListener("DOMContentLoaded", () => {
const selectorButtons = Array.from( const selectorButtons = Array.from(
document.querySelectorAll("[data-server-slug]"), document.querySelectorAll("[data-server-slug]"),
); );
const leaderboardTabButtons = Array.from(
document.querySelectorAll("[data-leaderboard-metric]"),
);
const summaryNode = document.getElementById("historical-summary"); const summaryNode = document.getElementById("historical-summary");
const rangeNode = document.getElementById("historical-range"); const rangeNode = document.getElementById("historical-range");
const summaryNoteNode = document.getElementById("historical-summary-note"); const summaryNoteNode = document.getElementById("historical-summary-note");
const weeklyStateNode = document.getElementById("weekly-top-kills-state"); const weeklyTitleNode = document.getElementById("weekly-ranking-title");
const weeklyTableNode = document.getElementById("weekly-top-kills-table"); const weeklyStateNode = document.getElementById("weekly-leaderboard-state");
const weeklyBodyNode = document.getElementById("weekly-top-kills-body"); 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 weeklyWindowNoteNode = document.getElementById("weekly-window-note");
const recentStateNode = document.getElementById("recent-matches-state"); const recentStateNode = document.getElementById("recent-matches-state");
const recentListNode = document.getElementById("recent-matches-list"); const recentListNode = document.getElementById("recent-matches-list");
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
let activeServerSlug = normalizeServerSlug(params.get("server")); let activeServerSlug = normalizeServerSlug(params.get("server"));
let activeLeaderboardMetric = normalizeLeaderboardMetric(params.get("metric"));
let refreshRequestId = 0;
const refreshHistoricalView = async () => { const refreshHistoricalView = async () => {
const requestId = refreshRequestId + 1;
refreshRequestId = requestId;
const activeMetricConfig = getLeaderboardMetricConfig(activeLeaderboardMetric);
syncActiveButtons(selectorButtons, activeServerSlug); syncActiveButtons(selectorButtons, activeServerSlug);
syncLeaderboardTabs(leaderboardTabButtons, activeLeaderboardMetric);
setRangeBadge(rangeNode, "Cargando rango temporal", false); setRangeBadge(rangeNode, "Cargando rango temporal", false);
summaryNoteNode.textContent = summaryNoteNode.textContent =
"Este bloque resume solo la cobertura ya registrada en la base local."; "Este bloque resume solo la cobertura ya registrada en la base local.";
renderSummaryLoading(summaryNode); renderSummaryLoading(summaryNode);
weeklyTitleNode.textContent = activeMetricConfig.title;
weeklyValueHeadingNode.textContent = activeMetricConfig.valueHeading;
weeklyWindowNoteNode.textContent = "Cargando ventana semanal..."; weeklyWindowNoteNode.textContent = "Cargando ventana semanal...";
setState(weeklyStateNode, "Cargando ranking semanal..."); setState(weeklyStateNode, "Cargando ranking semanal...");
setState(recentStateNode, "Cargando partidas recientes..."); setState(recentStateNode, "Cargando partidas recientes...");
weeklyTableNode.hidden = true; weeklyTableNode.hidden = true;
recentListNode.innerHTML = ""; recentListNode.innerHTML = "";
const [summaryResult, topKillsResult, recentMatchesResult] = const [summaryResult, leaderboardResult, recentMatchesResult] =
await Promise.allSettled([ await Promise.allSettled([
fetchJson( fetchJson(
`${backendBaseUrl}/api/historical/server-summary?server=${encodeURIComponent(activeServerSlug)}`, `${backendBaseUrl}/api/historical/server-summary?server=${encodeURIComponent(activeServerSlug)}`,
), ),
fetchJson( 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( fetchJson(
`${backendBaseUrl}/api/historical/recent-matches?server=${encodeURIComponent(activeServerSlug)}&limit=6`, `${backendBaseUrl}/api/historical/recent-matches?server=${encodeURIComponent(activeServerSlug)}&limit=6`,
), ),
]); ]);
if (requestId !== refreshRequestId) {
return;
}
hydrateSummary(summaryResult, summaryNode, rangeNode, summaryNoteNode); hydrateSummary(summaryResult, summaryNode, rangeNode, summaryNoteNode);
hydrateWeeklyTopKills( hydrateWeeklyLeaderboard(
topKillsResult, leaderboardResult,
weeklyStateNode, weeklyStateNode,
weeklyTableNode, weeklyTableNode,
weeklyBodyNode, weeklyBodyNode,
weeklyTitleNode,
weeklyValueHeadingNode,
weeklyWindowNoteNode, weeklyWindowNoteNode,
activeMetricConfig,
); );
hydrateRecentMatches(recentMatchesResult, recentStateNode, recentListNode); hydrateRecentMatches(recentMatchesResult, recentStateNode, recentListNode);
}; };
@@ -68,6 +116,22 @@ document.addEventListener("DOMContentLoaded", () => {
activeServerSlug = nextServerSlug; activeServerSlug = nextServerSlug;
params.set("server", activeServerSlug); 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()}`); window.history.replaceState({}, "", `?${params.toString()}`);
void refreshHistoricalView(); void refreshHistoricalView();
}); });
@@ -129,13 +193,18 @@ function hydrateSummary(result, summaryNode, rangeNode, noteNode) {
].join(""); ].join("");
} }
function hydrateWeeklyTopKills( function hydrateWeeklyLeaderboard(
result, result,
stateNode, stateNode,
tableNode, tableNode,
bodyNode, bodyNode,
titleNode,
valueHeadingNode,
noteNode, noteNode,
metricConfig,
) { ) {
titleNode.textContent = metricConfig.title;
valueHeadingNode.textContent = metricConfig.valueHeading;
if (result.status !== "fulfilled") { if (result.status !== "fulfilled") {
noteNode.textContent = noteNode.textContent =
"El ranking usa solo partidas cerradas dentro de la ultima ventana semanal."; "El ranking usa solo partidas cerradas dentro de la ultima ventana semanal.";
@@ -148,10 +217,7 @@ function hydrateWeeklyTopKills(
noteNode.textContent = buildWeeklyWindowNote(payload); noteNode.textContent = buildWeeklyWindowNote(payload);
const items = payload?.items; const items = payload?.items;
if (!Array.isArray(items) || items.length === 0) { if (!Array.isArray(items) || items.length === 0) {
setState( setState(stateNode, metricConfig.emptyMessage);
stateNode,
"Sin datos historicos suficientes para construir el ranking semanal.",
);
tableNode.hidden = true; tableNode.hidden = true;
return; return;
} }
@@ -162,9 +228,8 @@ function hydrateWeeklyTopKills(
<tr> <tr>
<td class="historical-table__position">#${escapeHtml(item.ranking_position)}</td> <td class="historical-table__position">#${escapeHtml(item.ranking_position)}</td>
<td>${escapeHtml(item.player?.name || "Jugador no identificado")}</td> <td>${escapeHtml(item.player?.name || "Jugador no identificado")}</td>
<td>${escapeHtml(formatNumber(item.weekly_kills))}</td> <td>${escapeHtml(formatNumber(item.metric_value))}</td>
<td>${escapeHtml(formatNumber(item.matches_considered))}</td> <td>${escapeHtml(formatNumber(item.matches_considered))}</td>
<td>${escapeHtml(item.server?.name || "Servidor no disponible")}</td>
</tr> </tr>
`, `,
) )
@@ -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) { function normalizeServerSlug(rawValue) {
const normalized = typeof rawValue === "string" ? rawValue.trim() : ""; const normalized = typeof rawValue === "string" ? rawValue.trim() : "";
if (HISTORICAL_SERVER_SLUGS.includes(normalized)) { if (HISTORICAL_SERVER_SLUGS.includes(normalized)) {
@@ -273,6 +346,22 @@ function normalizeServerSlug(rawValue) {
return DEFAULT_HISTORICAL_SERVER; 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) { function buildRangeLabel(start, end) {
if (!start && !end) { if (!start && !end) {
return ""; return "";

View File

@@ -94,27 +94,69 @@
<div class="panel__header historical-panel__header"> <div class="panel__header historical-panel__header">
<div> <div>
<p class="eyebrow eyebrow--section">Ranking semanal</p> <p class="eyebrow eyebrow--section">Ranking semanal</p>
<h2 id="weekly-ranking-title">Top kills de los ultimos 7 dias</h2> <h2 id="weekly-ranking-title">Ranking semanal del servidor activo</h2>
<p class="historical-panel__note" id="weekly-window-note"> <p class="historical-panel__note" id="weekly-window-note">
Cargando ventana semanal... Cargando ventana semanal...
</p> </p>
</div> </div>
</div> </div>
<p class="historical-state" id="weekly-top-kills-state" aria-live="polite"> <div
class="historical-tabs"
id="weekly-leaderboard-tabs"
role="tablist"
aria-label="Metricas del ranking semanal"
>
<button
class="historical-tab is-active"
type="button"
role="tab"
aria-selected="true"
data-leaderboard-metric="kills"
>
Top kills
</button>
<button
class="historical-tab"
type="button"
role="tab"
aria-selected="false"
data-leaderboard-metric="deaths"
>
Top muertes
</button>
<button
class="historical-tab"
type="button"
role="tab"
aria-selected="false"
data-leaderboard-metric="matches_over_100_kills"
>
Partidas 100+ kills
</button>
<button
class="historical-tab"
type="button"
role="tab"
aria-selected="false"
data-leaderboard-metric="support"
>
Soporte
</button>
</div>
<p class="historical-state" id="weekly-leaderboard-state" aria-live="polite">
Cargando ranking semanal... Cargando ranking semanal...
</p> </p>
<div class="historical-table-shell"> <div class="historical-table-shell">
<table class="historical-table" id="weekly-top-kills-table" hidden> <table class="historical-table" id="weekly-leaderboard-table" hidden>
<thead> <thead>
<tr> <tr>
<th>Pos.</th> <th>Pos.</th>
<th>Jugador</th> <th>Jugador</th>
<th>Kills</th> <th id="weekly-leaderboard-value-heading">Kills</th>
<th>Partidas</th> <th>Partidas</th>
<th>Servidor</th>
</tr> </tr>
</thead> </thead>
<tbody id="weekly-top-kills-body"></tbody> <tbody id="weekly-leaderboard-body"></tbody>
</table> </table>
</div> </div>
</div> </div>