Add historical leaderboard tabs UI
This commit is contained in:
72
ai/tasks/done/TASK-038-historical-leaderboards-tabs-ui.md
Normal file
72
ai/tasks/done/TASK-038-historical-leaderboards-tabs-ui.md
Normal 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.
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
<tr>
|
||||
<td class="historical-table__position">#${escapeHtml(item.ranking_position)}</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(item.server?.name || "Servidor no disponible")}</td>
|
||||
</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) {
|
||||
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 "";
|
||||
|
||||
@@ -94,27 +94,69 @@
|
||||
<div class="panel__header historical-panel__header">
|
||||
<div>
|
||||
<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">
|
||||
Cargando ventana semanal...
|
||||
</p>
|
||||
</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...
|
||||
</p>
|
||||
<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>
|
||||
<tr>
|
||||
<th>Pos.</th>
|
||||
<th>Jugador</th>
|
||||
<th>Kills</th>
|
||||
<th id="weekly-leaderboard-value-heading">Kills</th>
|
||||
<th>Partidas</th>
|
||||
<th>Servidor</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="weekly-top-kills-body"></tbody>
|
||||
<tbody id="weekly-leaderboard-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user