Load annual ranking by default and add KPM columns
This commit is contained in:
@@ -0,0 +1,171 @@
|
|||||||
|
---
|
||||||
|
id: TASK-207
|
||||||
|
title: Annual ranking default load and KPM columns
|
||||||
|
status: done
|
||||||
|
type: frontend
|
||||||
|
team: Frontend Senior
|
||||||
|
supporting_teams:
|
||||||
|
- Experto en interfaz
|
||||||
|
roadmap_item: foundation
|
||||||
|
priority: medium
|
||||||
|
---
|
||||||
|
|
||||||
|
# TASK-207 - Annual ranking default load and KPM columns
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Ajustar el ranking anual publico en `stats` para que muestre la temporada fija 2026 con carga automatica inicial y anadir la columna `KPM` en los listados publicos multi-registro donde existan datos suficientes sin tocar backend ni endpoints.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Tras `TASK-206`, la presentacion publica del frontend ya es consistente, pero el bloque anual de `stats` sigue requiriendo seleccion manual de anio y una accion extra para cargar un ranking que ahora debe mostrarse como lectura fija de temporada. Ademas, las tablas publicas con varios jugadores no muestran de forma uniforme `KPM` aunque ya existen datos o pueden derivarse desde `kills` y `matches_considered`.
|
||||||
|
|
||||||
|
Preserve the current product identity: Spanish-speaking HLL Vietnam community, military/Vietnam/tactical/sober visual direction and controlled repository evolution.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. Crear la task y moverla a `ai/tasks/in-progress`.
|
||||||
|
2. Fijar el ranking anual de `stats` a la temporada 2026 sin selector editable de anio ni boton obligatorio de carga.
|
||||||
|
3. Cargar automaticamente el ranking anual al abrir `stats.html`, manteniendo estados de carga y error controlados.
|
||||||
|
4. Cambiar la tabla anual para mostrar `Kills` y anadir `KPM`, usando `kills_per_match` cuando exista o calculandolo desde `kills / matches_considered`.
|
||||||
|
5. Revisar `ranking.html` y `historico.html` para unificar la etiqueta visual a `KPM` y anadir la columna donde aplique sin duplicarla.
|
||||||
|
6. Validar por inspeccion, revisar `git diff --name-only`, documentar exclusiones y mover la task a `ai/tasks/done` al terminar.
|
||||||
|
|
||||||
|
## Files to Read First
|
||||||
|
|
||||||
|
- `AGENTS.md`
|
||||||
|
- `ai/repo-context.md`
|
||||||
|
- `ai/architecture-index.md`
|
||||||
|
- `frontend/stats.html`
|
||||||
|
- `frontend/ranking.html`
|
||||||
|
- `frontend/historico.html`
|
||||||
|
- `frontend/assets/js/stats.js`
|
||||||
|
- `frontend/assets/js/ranking.js`
|
||||||
|
- `frontend/assets/js/historico.js`
|
||||||
|
- `frontend/assets/css/styles.css`
|
||||||
|
- `ai/tasks/done/TASK-206-public-frontend-visual-consistency-and-annual-ranking-layout.md`
|
||||||
|
|
||||||
|
## Expected Files to Modify
|
||||||
|
|
||||||
|
- `ai/tasks/done/TASK-207-annual-ranking-default-load-and-kpm-columns.md`
|
||||||
|
- `frontend/stats.html`
|
||||||
|
- `frontend/ranking.html`
|
||||||
|
- `frontend/historico.html`
|
||||||
|
- `frontend/assets/js/stats.js`
|
||||||
|
- `frontend/assets/js/ranking.js`
|
||||||
|
- `frontend/assets/js/historico.js`
|
||||||
|
- `frontend/assets/css/styles.css`
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Keep the change minimal.
|
||||||
|
- Preserve HLL Vietnam project identity.
|
||||||
|
- Do not introduce unnecessary frameworks or dependencies.
|
||||||
|
- Do not implement backend functionality unless the task explicitly requires it.
|
||||||
|
- Do not expand Elo/MMR, historical workers or RCON server #03 handling unless the task explicitly requires it.
|
||||||
|
- Do not overwrite repository-specific context with generic platform template text.
|
||||||
|
- No ejecutar `ai-platform run`.
|
||||||
|
- No modificar `backend/`, endpoints, logica de datos, generacion de snapshots ni scripts backend.
|
||||||
|
- No tocar `ai/system-metrics.md`.
|
||||||
|
- No tocar `frontend/assets/img/weapons/`, assets/SVGs de armas ni copias relacionadas.
|
||||||
|
- No reactivar Elo/MMR ni reintroducir Comunidad Hispana #03.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
Before completing the task ensure:
|
||||||
|
|
||||||
|
- `stats.html` no muestra selector editable de anio para el ranking anual
|
||||||
|
- `stats.html` muestra de forma simple la temporada fija 2026
|
||||||
|
- el ranking anual se carga automaticamente al abrir la pagina
|
||||||
|
- ya no hace falta pulsar un boton para la carga inicial del ranking anual
|
||||||
|
- la tabla anual muestra `Kills` y `KPM` y ya no muestra `Valor / Kills`
|
||||||
|
- `KPM` usa `kills_per_match` si existe o `kills / matches_considered` con 2 decimales cuando haya datos suficientes
|
||||||
|
- `ranking.html` usa la etiqueta visual `KPM` sin duplicar columnas
|
||||||
|
- `historico.html` y `stats.html` solo muestran `KPM` adicional donde aplique por datos disponibles
|
||||||
|
- `git diff --name-only` matches the expected scope
|
||||||
|
- no unrelated files were modified
|
||||||
|
- integration tests are run when relevant and configured
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
Archivos modificados:
|
||||||
|
|
||||||
|
- `frontend/stats.html`
|
||||||
|
- `frontend/ranking.html`
|
||||||
|
- `frontend/historico.html`
|
||||||
|
- `frontend/assets/js/stats.js`
|
||||||
|
- `frontend/assets/js/ranking.js`
|
||||||
|
- `frontend/assets/js/historico.js`
|
||||||
|
- `frontend/assets/js/historico-partida.js`
|
||||||
|
- `frontend/assets/js/main.js`
|
||||||
|
- `frontend/assets/css/styles.css`
|
||||||
|
- `frontend/assets/css/historico-scoreboard-detail.css`
|
||||||
|
- `ai/tasks/in-progress/TASK-207-annual-ranking-default-load-and-kpm-columns.md`
|
||||||
|
|
||||||
|
Temporada anual fija:
|
||||||
|
|
||||||
|
- Se elimino el selector editable de anio y cualquier control de carga manual del bloque anual en `stats`.
|
||||||
|
- La interfaz muestra la temporada fija con un bloque visual simple `Temporada 2026`.
|
||||||
|
- El titulo del bloque anual queda orientado a la lectura fija: `Top 20 temporada 2026`.
|
||||||
|
|
||||||
|
Carga automatica del ranking anual:
|
||||||
|
|
||||||
|
- `stats.js` deja de leer un input de anio y fija `annualDefaultYear = 2026`.
|
||||||
|
- Tras confirmar `health`, la pagina dispara automaticamente la carga del ranking anual usando `year=2026`, `metric=kills`, `limit=20` y `server_id=all`.
|
||||||
|
- Se mantienen mensajes de carga, vacio y error controlados; ya no hace falta pulsar `Cargar ranking anual`.
|
||||||
|
|
||||||
|
KPM anadido y unificado:
|
||||||
|
|
||||||
|
- `stats` anual ahora muestra columnas `Posicion`, `Jugador`, `Kills`, `Partidas`, `KPM`, `Muertes`, `Teamkills` y `K/D`.
|
||||||
|
- `ranking.html` cambia la etiqueta visual de `kills_per_match` a `KPM` tanto en el selector como en la tabla.
|
||||||
|
- `ranking.js` evita duplicar `KPM` cuando la metrica activa ya es `kills_per_match`, ocultando la columna secundaria en ese caso.
|
||||||
|
- `historico.html` y `historico.js` anaden una columna `KPM` al ranking multi-registro; si no hay datos suficientes se muestra `-`.
|
||||||
|
|
||||||
|
Busqueda de jugador endurecida:
|
||||||
|
|
||||||
|
- `stats` ahora exige un minimo de 4 caracteres utiles antes de permitir la busqueda.
|
||||||
|
- El boton `Buscar jugador` queda deshabilitado hasta cumplir el minimo y la validacion tambien se aplica al pulsar o enviar con Enter.
|
||||||
|
- Si el texto no alcanza el minimo, el frontend muestra `Introduce al menos 4 caracteres para buscar un jugador.` y no lanza la llamada a `/api/stats/players/search`.
|
||||||
|
|
||||||
|
Logos de clanes ampliados:
|
||||||
|
|
||||||
|
- Se aumento la presencia visual general de los logos de la seccion de clanes mediante layout y CSS, sin tocar imagenes.
|
||||||
|
- `BxB` recibe un ajuste especifico adicional para que deje de verse pequeno dentro de su tarjeta.
|
||||||
|
- El ajuste mantiene responsive y no reemplaza assets.
|
||||||
|
|
||||||
|
Brands en botones externos:
|
||||||
|
|
||||||
|
- El detalle de jugador dentro de `historico-partida` ahora usa los logos disponibles en `frontend/assets/img/brands/` para `Hellor`, `HLL Records` y `Helo`.
|
||||||
|
- `Steam` conserva boton solo con texto porque no existe logo brand disponible en esa carpeta.
|
||||||
|
- Los enlaces actuales no cambian; solo se enriquece su presentacion visual con logos decorativos cuando hay asset.
|
||||||
|
|
||||||
|
Calculo de KPM:
|
||||||
|
|
||||||
|
- Si el backend ya entrega `kills_per_match`, el frontend usa ese valor.
|
||||||
|
- Si no existe `kills_per_match` y hay `kills` mas `matches_considered`, el frontend calcula `kills / matches_considered`.
|
||||||
|
- Si faltan datos o `matches_considered <= 0`, el frontend muestra `-`.
|
||||||
|
- La comprobacion pedida `1222 / 32` da `38.19`.
|
||||||
|
|
||||||
|
Validaciones realizadas:
|
||||||
|
|
||||||
|
- `git status --short --untracked-files=all` ejecutado antes de modificar.
|
||||||
|
- Confirmacion previa de cambios no relacionados en `ai/system-metrics.md` y en assets/SVGs de armas; no se tocaron.
|
||||||
|
- Lectura previa de `AGENTS.md`, `ai/repo-context.md`, `ai/architecture-index.md`, `frontend/stats.html`, `frontend/ranking.html`, `frontend/historico.html`, `frontend/assets/js/stats.js`, `frontend/assets/js/ranking.js`, `frontend/assets/js/historico.js`, `frontend/assets/css/styles.css` y `TASK-206`.
|
||||||
|
- Lectura adicional para esta ampliacion de `frontend/historico-partida.html`, `frontend/assets/js/historico-partida.js`, `frontend/assets/js/main.js`, `frontend/assets/css/historico-scoreboard-detail.css`, rutas existentes en `frontend/assets/img/brands/` y rutas existentes de la seccion de clanes.
|
||||||
|
- Parseo sintactico local con `node --check frontend/assets/js/stats.js`, `node --check frontend/assets/js/historico.js`, `node --check frontend/assets/js/ranking.js` y `node --check frontend/assets/js/historico-partida.js`.
|
||||||
|
- Verificacion especifica por inspeccion del minimo de 4 caracteres, de la presencia de logos de clan ampliados y de la asignacion de brands a botones externos sin rutas rotas.
|
||||||
|
- Verificacion por inspeccion de presencia/eliminacion de textos y columnas clave con `rg`.
|
||||||
|
- Revision de `git diff --name-only` filtrado a los archivos esperados para confirmar alcance acotado.
|
||||||
|
- No existe una validacion frontend automatizada configurada para este alcance; la validacion queda documentada por inspeccion y parseo local.
|
||||||
|
|
||||||
|
Confirmaciones de alcance:
|
||||||
|
|
||||||
|
- No se tocaron `backend/`, endpoints, logica de datos, snapshots ni scripts backend.
|
||||||
|
- No se tocaron `ai/system-metrics.md`, `frontend/assets/img/weapons/` ni SVGs de armas.
|
||||||
|
- No se reactivo Elo/MMR ni se reintrodujo Comunidad Hispana #03.
|
||||||
|
|
||||||
|
## Change Budget
|
||||||
|
|
||||||
|
- Prefer fewer than 5 modified files.
|
||||||
|
- Prefer changes under 200 lines when feasible.
|
||||||
|
- Split the work into follow-up tasks if limits are exceeded.
|
||||||
@@ -267,6 +267,7 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
min-height: 32px;
|
min-height: 32px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
padding: 0 11px;
|
padding: 0 11px;
|
||||||
border: 1px solid rgba(210, 182, 118, 0.34);
|
border: 1px solid rgba(210, 182, 118, 0.34);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
@@ -278,6 +279,16 @@
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.historical-player-profile-link__brand {
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
max-width: 20px;
|
||||||
|
max-height: 20px;
|
||||||
|
object-fit: contain;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.28));
|
||||||
|
}
|
||||||
|
|
||||||
.historical-player-profile-links a:hover,
|
.historical-player-profile-links a:hover,
|
||||||
.historical-player-profile-links a:focus-visible {
|
.historical-player-profile-links a:focus-visible {
|
||||||
border-color: rgba(210, 182, 118, 0.62);
|
border-color: rgba(210, 182, 118, 0.62);
|
||||||
|
|||||||
@@ -477,6 +477,14 @@ h2 {
|
|||||||
box-shadow: 0 18px 36px rgba(110, 127, 72, 0.45);
|
box-shadow: 0 18px 36px rgba(110, 127, 72, 0.45);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.discord-button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.58;
|
||||||
|
filter: saturate(0.7);
|
||||||
|
box-shadow: none;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: -28px;
|
margin-top: -28px;
|
||||||
@@ -883,14 +891,14 @@ h2 {
|
|||||||
|
|
||||||
.clan-card__brand {
|
.clan-card__brand {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(92px, 112px) minmax(0, 1fr);
|
grid-template-columns: minmax(124px, 154px) minmax(0, 1fr);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 18px;
|
gap: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.clan-card__logo {
|
.clan-card__logo {
|
||||||
min-height: 92px;
|
min-height: 124px;
|
||||||
padding: 12px;
|
padding: 14px;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
border: 1px dashed rgba(183, 201, 125, 0.3);
|
border: 1px dashed rgba(183, 201, 125, 0.3);
|
||||||
@@ -898,33 +906,45 @@ h2 {
|
|||||||
background: linear-gradient(180deg, rgba(19, 24, 16, 0.82), rgba(10, 13, 9, 0.66));
|
background: linear-gradient(180deg, rgba(19, 24, 16, 0.82), rgba(10, 13, 9, 0.66));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.clan-card__logo--standard {
|
||||||
|
min-height: 128px;
|
||||||
|
}
|
||||||
|
|
||||||
.clan-card__logo--wide {
|
.clan-card__logo--wide {
|
||||||
padding-inline: 8px;
|
padding-inline: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.clan-card__logo--shield {
|
.clan-card__logo--shield {
|
||||||
padding-inline: 18px;
|
padding-inline: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.clan-card--bxb .clan-card__brand {
|
.clan-card--bxb .clan-card__brand {
|
||||||
grid-template-columns: minmax(112px, 138px) minmax(0, 1fr);
|
grid-template-columns: minmax(146px, 184px) minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.clan-card__logo--bxb {
|
.clan-card__logo--bxb {
|
||||||
min-height: 108px;
|
min-height: 148px;
|
||||||
padding: 10px;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.clan-card__logo img {
|
.clan-card__logo img {
|
||||||
width: auto;
|
width: auto;
|
||||||
height: auto;
|
height: auto;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
max-height: 84px;
|
max-height: 108px;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.clan-card__logo--wide img {
|
||||||
|
max-height: 112px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clan-card__logo--shield img {
|
||||||
|
max-height: 116px;
|
||||||
|
}
|
||||||
|
|
||||||
.clan-card__logo--bxb img {
|
.clan-card__logo--bxb img {
|
||||||
max-height: 100px;
|
max-height: 132px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.clan-card__logo-placeholder {
|
.clan-card__logo-placeholder {
|
||||||
@@ -1111,6 +1131,30 @@ h2 {
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stats-annual-season {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 18px 0 14px;
|
||||||
|
padding: 0.8rem 1rem;
|
||||||
|
border: 1px solid rgba(183, 201, 125, 0.24);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: linear-gradient(180deg, rgba(30, 36, 27, 0.78), rgba(12, 15, 11, 0.9));
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-annual-season__label {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.74rem;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-annual-season__value {
|
||||||
|
color: var(--accent-strong);
|
||||||
|
font-size: 1.12rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
.stats-annual-meta {
|
.stats-annual-meta {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@@ -1497,6 +1541,11 @@ h2 {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ranking-table th[hidden],
|
||||||
|
.ranking-table td[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.ranking-player {
|
.ranking-player {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
|
|||||||
BIN
frontend/assets/img/brands/helo-system.png
Normal file
BIN
frontend/assets/img/brands/helo-system.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.5 KiB |
BIN
frontend/assets/img/brands/hllor.webp
Normal file
BIN
frontend/assets/img/brands/hllor.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.8 KiB |
BIN
frontend/assets/img/brands/hllrecords.png
Normal file
BIN
frontend/assets/img/brands/hllrecords.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.0 KiB |
@@ -40,6 +40,25 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
void loadMatchDetail({ backendBaseUrl, serverSlug, matchId, nodes });
|
void loadMatchDetail({ backendBaseUrl, serverSlug, matchId, nodes });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const EXTERNAL_PROFILE_BRANDS = Object.freeze({
|
||||||
|
hellor: Object.freeze({
|
||||||
|
label: "Hellor",
|
||||||
|
logoSrc: "./assets/img/brands/hllor.webp",
|
||||||
|
}),
|
||||||
|
hll_records: Object.freeze({
|
||||||
|
label: "HLL Records",
|
||||||
|
logoSrc: "./assets/img/brands/hllrecords.png",
|
||||||
|
}),
|
||||||
|
helo: Object.freeze({
|
||||||
|
label: "Helo",
|
||||||
|
logoSrc: "./assets/img/brands/helo-system.png",
|
||||||
|
}),
|
||||||
|
steam: Object.freeze({
|
||||||
|
label: "Steam",
|
||||||
|
logoSrc: "",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
async function loadMatchDetail({ backendBaseUrl, serverSlug, matchId, nodes }) {
|
async function loadMatchDetail({ backendBaseUrl, serverSlug, matchId, nodes }) {
|
||||||
try {
|
try {
|
||||||
const payload = await fetchJson(
|
const payload = await fetchJson(
|
||||||
@@ -513,13 +532,17 @@ function renderPlayerStatsPanel(player, item, context) {
|
|||||||
|
|
||||||
function renderExternalProfilesSection(player) {
|
function renderExternalProfilesSection(player) {
|
||||||
const links = [
|
const links = [
|
||||||
["steam", "Steam"],
|
"steam",
|
||||||
["hellor", "Hellor"],
|
"hellor",
|
||||||
["hll_records", "HLL Records"],
|
"hll_records",
|
||||||
["helo", "Helo"],
|
"helo",
|
||||||
]
|
]
|
||||||
.map(([key, label]) => [label, player.external_profile_links?.[key]])
|
.map((key) => ({
|
||||||
.filter(([, href]) => typeof href === "string" && href.trim());
|
key,
|
||||||
|
brand: EXTERNAL_PROFILE_BRANDS[key] || { label: key, logoSrc: "" },
|
||||||
|
href: player.external_profile_links?.[key],
|
||||||
|
}))
|
||||||
|
.filter((entry) => typeof entry.href === "string" && entry.href.trim());
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<article class="historical-player-stats-panel__section historical-player-stats-panel__profiles">
|
<article class="historical-player-stats-panel__section historical-player-stats-panel__profiles">
|
||||||
@@ -530,9 +553,14 @@ function renderExternalProfilesSection(player) {
|
|||||||
<div class="historical-player-profile-links">
|
<div class="historical-player-profile-links">
|
||||||
${links
|
${links
|
||||||
.map(
|
.map(
|
||||||
([label, href]) => `
|
({ brand, href }) => `
|
||||||
<a href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer">
|
<a href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer">
|
||||||
${escapeHtml(label)}
|
${
|
||||||
|
brand.logoSrc
|
||||||
|
? `<img class="historical-player-profile-link__brand" src="${escapeHtml(brand.logoSrc)}" alt="" aria-hidden="true" decoding="async" loading="lazy" />`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
<span>${escapeHtml(brand.label)}</span>
|
||||||
</a>
|
</a>
|
||||||
`,
|
`,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -613,14 +613,21 @@ function hydrateWeeklyLeaderboard(
|
|||||||
|
|
||||||
bodyNode.innerHTML = items
|
bodyNode.innerHTML = items
|
||||||
.map(
|
.map(
|
||||||
(item) => `
|
(item) => {
|
||||||
|
const kills = resolveHistoricalKills(item, metricConfig);
|
||||||
|
const matches = Number(item.matches_considered);
|
||||||
|
const kpm = formatHistoricalKpm(item?.kills_per_match, kills, matches);
|
||||||
|
|
||||||
|
return `
|
||||||
<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.metric_value))}</td>
|
<td>${escapeHtml(formatNumber(item.metric_value))}</td>
|
||||||
<td>${escapeHtml(formatNumber(item.matches_considered))}</td>
|
<td>${escapeHtml(formatNumber(item.matches_considered))}</td>
|
||||||
|
<td>${escapeHtml(kpm)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
`,
|
`;
|
||||||
|
},
|
||||||
)
|
)
|
||||||
.join("");
|
.join("");
|
||||||
stateNode.hidden = true;
|
stateNode.hidden = true;
|
||||||
@@ -1667,6 +1674,37 @@ function formatTopMaps(topMaps) {
|
|||||||
.join(" / ");
|
.join(" / ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveHistoricalKills(item, metricConfig) {
|
||||||
|
const directKills = Number(item?.kills);
|
||||||
|
if (Number.isFinite(directKills)) {
|
||||||
|
return directKills;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metricConfig?.key === "kills") {
|
||||||
|
const metricValue = Number(item?.metric_value);
|
||||||
|
if (Number.isFinite(metricValue)) {
|
||||||
|
return metricValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Number.NaN;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatHistoricalKpm(rawKillsPerMatch, rawKills, rawMatches) {
|
||||||
|
const directValue = Number(rawKillsPerMatch);
|
||||||
|
if (Number.isFinite(directValue)) {
|
||||||
|
return formatDecimal(directValue, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const kills = Number(rawKills);
|
||||||
|
const matches = Number(rawMatches);
|
||||||
|
if (!Number.isFinite(kills) || !Number.isFinite(matches) || matches <= 0) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatDecimal(kills / matches, 2);
|
||||||
|
}
|
||||||
|
|
||||||
function formatDateOnly(timestamp) {
|
function formatDateOnly(timestamp) {
|
||||||
if (!timestamp) {
|
if (!timestamp) {
|
||||||
return "Fecha no disponible";
|
return "Fecha no disponible";
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const COMMUNITY_CLANS = Object.freeze([
|
|||||||
"Clan activo de la comunidad, con acceso directo a su discord.",
|
"Clan activo de la comunidad, con acceso directo a su discord.",
|
||||||
logoSrc: "./assets/img/clans/lcm.png",
|
logoSrc: "./assets/img/clans/lcm.png",
|
||||||
logoAlt: "Logo de LCM",
|
logoAlt: "Logo de LCM",
|
||||||
logoClassName: "",
|
logoClassName: "clan-card__logo--standard",
|
||||||
discordUrl: "https://discord.gg/9F9S353QZv",
|
discordUrl: "https://discord.gg/9F9S353QZv",
|
||||||
discordLabel: "Abrir Discord",
|
discordLabel: "Abrir Discord",
|
||||||
},
|
},
|
||||||
@@ -53,7 +53,7 @@ const COMMUNITY_CLANS = Object.freeze([
|
|||||||
"Clan activo de la comunidad, con acceso directo a su discord.",
|
"Clan activo de la comunidad, con acceso directo a su discord.",
|
||||||
logoSrc: "./assets/img/clans/h9h.png",
|
logoSrc: "./assets/img/clans/h9h.png",
|
||||||
logoAlt: "",
|
logoAlt: "",
|
||||||
logoClassName: "",
|
logoClassName: "clan-card__logo--standard",
|
||||||
discordUrl: "https://discord.gg/tYnXK7MQjB",
|
discordUrl: "https://discord.gg/tYnXK7MQjB",
|
||||||
discordLabel: "Abrir Discord",
|
discordLabel: "Abrir Discord",
|
||||||
placeholderLabel: "H9H",
|
placeholderLabel: "H9H",
|
||||||
@@ -77,7 +77,7 @@ const COMMUNITY_CLANS = Object.freeze([
|
|||||||
"Clan activo de la comunidad, con acceso directo a su discord.",
|
"Clan activo de la comunidad, con acceso directo a su discord.",
|
||||||
logoSrc: "./assets/img/clans/7dv.png",
|
logoSrc: "./assets/img/clans/7dv.png",
|
||||||
logoAlt: "Logo de 7dv",
|
logoAlt: "Logo de 7dv",
|
||||||
logoClassName: "",
|
logoClassName: "clan-card__logo--standard",
|
||||||
discordUrl: "https://discord.gg/3sxNQZwrg6",
|
discordUrl: "https://discord.gg/3sxNQZwrg6",
|
||||||
discordLabel: "Abrir Discord",
|
discordLabel: "Abrir Discord",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
const tableNode = document.getElementById("ranking-table");
|
const tableNode = document.getElementById("ranking-table");
|
||||||
const tableBodyNode = document.getElementById("ranking-table-body");
|
const tableBodyNode = document.getElementById("ranking-table-body");
|
||||||
const metricHeadingNode = document.getElementById("ranking-metric-heading");
|
const metricHeadingNode = document.getElementById("ranking-metric-heading");
|
||||||
|
const kpmHeadingNode = document.getElementById("ranking-kpm-heading");
|
||||||
const emptyNode = document.getElementById("ranking-empty");
|
const emptyNode = document.getElementById("ranking-empty");
|
||||||
const filterNoteNode = document.getElementById("ranking-filter-note");
|
const filterNoteNode = document.getElementById("ranking-filter-note");
|
||||||
|
|
||||||
@@ -338,7 +339,7 @@
|
|||||||
if (statusCode === 400 && normalizedMessage.includes("metric")) {
|
if (statusCode === 400 && normalizedMessage.includes("metric")) {
|
||||||
setRankingState("warning", "La m\u00e9trica solicitada no est\u00e1 soportada.");
|
setRankingState("warning", "La m\u00e9trica solicitada no est\u00e1 soportada.");
|
||||||
renderEmptyState(
|
renderEmptyState(
|
||||||
"Usa kills, deaths, teamkills, partidas jugadas, K/D o kills por partida.",
|
"Usa kills, deaths, teamkills, partidas jugadas, K/D o KPM.",
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -414,6 +415,7 @@
|
|||||||
if (tableBodyNode) {
|
if (tableBodyNode) {
|
||||||
tableBodyNode.innerHTML = items.map((item) => renderRow(item, metric)).join("");
|
tableBodyNode.innerHTML = items.map((item) => renderRow(item, metric)).join("");
|
||||||
}
|
}
|
||||||
|
syncKpmColumn(metric);
|
||||||
if (tableNode) {
|
if (tableNode) {
|
||||||
tableNode.hidden = false;
|
tableNode.hidden = false;
|
||||||
}
|
}
|
||||||
@@ -455,6 +457,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderRow(item, metric) {
|
function renderRow(item, metric) {
|
||||||
|
const kpm = formatKpm(item.kills_per_match, item.kills, item.matches_considered);
|
||||||
|
const hideKpmColumn = metric === "kills_per_match";
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<tr>
|
<tr>
|
||||||
<td>#${safeInt(item.ranking_position, 0)}</td>
|
<td>#${safeInt(item.ranking_position, 0)}</td>
|
||||||
@@ -470,11 +475,28 @@
|
|||||||
<td>${safeInt(item.teamkills, 0)}</td>
|
<td>${safeInt(item.teamkills, 0)}</td>
|
||||||
<td>${safeInt(item.matches_considered, 0)}</td>
|
<td>${safeInt(item.matches_considered, 0)}</td>
|
||||||
<td>${safeDecimal(item.kd_ratio, 2, "0.00")}</td>
|
<td>${safeDecimal(item.kd_ratio, 2, "0.00")}</td>
|
||||||
<td>${safeDecimal(item.kills_per_match, 2, "0.00")}</td>
|
${hideKpmColumn ? "" : `<td>${kpm}</td>`}
|
||||||
</tr>
|
</tr>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncKpmColumn(metric) {
|
||||||
|
if (!tableNode || !kpmHeadingNode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const kpmColumnIndex = kpmHeadingNode.cellIndex + 1;
|
||||||
|
const hideKpmColumn = metric === "kills_per_match";
|
||||||
|
kpmHeadingNode.hidden = hideKpmColumn;
|
||||||
|
|
||||||
|
tableNode.querySelectorAll("tbody tr").forEach((row) => {
|
||||||
|
const cell = row.children[kpmColumnIndex - 1];
|
||||||
|
if (cell) {
|
||||||
|
cell.hidden = hideKpmColumn;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function labelForWindow(data) {
|
function labelForWindow(data) {
|
||||||
if (String(data.timeframe || "") === "annual") {
|
if (String(data.timeframe || "") === "annual") {
|
||||||
return `A\u00f1o ${safeInt(data.year, currentYear)}`;
|
return `A\u00f1o ${safeInt(data.year, currentYear)}`;
|
||||||
@@ -509,7 +531,7 @@
|
|||||||
teamkills: "Teamkills",
|
teamkills: "Teamkills",
|
||||||
matches_considered: "Partidas jugadas",
|
matches_considered: "Partidas jugadas",
|
||||||
kd_ratio: "K/D",
|
kd_ratio: "K/D",
|
||||||
kills_per_match: "Kills por partida",
|
kills_per_match: "KPM",
|
||||||
};
|
};
|
||||||
return labels[metric] || "Kills";
|
return labels[metric] || "Kills";
|
||||||
}
|
}
|
||||||
@@ -540,6 +562,21 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatKpm(rawKillsPerMatch, rawKills, rawMatches) {
|
||||||
|
const directValue = Number(rawKillsPerMatch);
|
||||||
|
if (Number.isFinite(directValue)) {
|
||||||
|
return safeDecimal(directValue, 2, "0.00");
|
||||||
|
}
|
||||||
|
|
||||||
|
const kills = Number(rawKills);
|
||||||
|
const matches = Number(rawMatches);
|
||||||
|
if (!Number.isFinite(kills) || !Number.isFinite(matches) || matches <= 0) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
return safeDecimal(kills / matches, 2, "0.00");
|
||||||
|
}
|
||||||
|
|
||||||
function safeParseJson(response) {
|
function safeParseJson(response) {
|
||||||
if (!response) {
|
if (!response) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
const backendBaseUrl = document.body.dataset.backendBaseUrl || "http://127.0.0.1:8000";
|
const backendBaseUrl = document.body.dataset.backendBaseUrl || "http://127.0.0.1:8000";
|
||||||
const searchForm = document.getElementById("stats-search-form");
|
const searchForm = document.getElementById("stats-search-form");
|
||||||
const searchInput = document.getElementById("stats-search-input");
|
const searchInput = document.getElementById("stats-search-input");
|
||||||
|
const searchSubmitButton = document.getElementById("stats-search-submit");
|
||||||
const searchStateNode = document.getElementById("stats-search-state");
|
const searchStateNode = document.getElementById("stats-search-state");
|
||||||
const searchHelpNode = document.getElementById("stats-search-help");
|
const searchHelpNode = document.getElementById("stats-search-help");
|
||||||
const resultListNode = document.getElementById("stats-result-list");
|
const resultListNode = document.getElementById("stats-result-list");
|
||||||
@@ -12,12 +13,11 @@
|
|||||||
const summaryGrid = document.getElementById("stats-summary-grid");
|
const summaryGrid = document.getElementById("stats-summary-grid");
|
||||||
const weeklySummaryNode = document.getElementById("stats-weekly-summary");
|
const weeklySummaryNode = document.getElementById("stats-weekly-summary");
|
||||||
const monthlySummaryNode = document.getElementById("stats-monthly-summary");
|
const monthlySummaryNode = document.getElementById("stats-monthly-summary");
|
||||||
const annualForm = document.getElementById("stats-annual-form");
|
|
||||||
const annualYearInput = document.getElementById("stats-annual-year");
|
|
||||||
const annualStateNode = document.getElementById("stats-annual-state");
|
const annualStateNode = document.getElementById("stats-annual-state");
|
||||||
const annualContentNode = document.getElementById("stats-annual-content");
|
const annualContentNode = document.getElementById("stats-annual-content");
|
||||||
|
|
||||||
const annualDefaultYear = new Date().getUTCFullYear();
|
const annualDefaultYear = 2026;
|
||||||
|
const minimumSearchQueryLength = 4;
|
||||||
const annualMetric = "kills";
|
const annualMetric = "kills";
|
||||||
const annualMetricSupportedOnly = "kills";
|
const annualMetricSupportedOnly = "kills";
|
||||||
const annualLimit = 20;
|
const annualLimit = 20;
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
"Backend no disponible. No se pueden buscar jugadores ni cargar su perfil.",
|
"Backend no disponible. No se pueden buscar jugadores ni cargar su perfil.",
|
||||||
backendUnavailableForAnnual:
|
backendUnavailableForAnnual:
|
||||||
"Backend no disponible. No se puede consultar el ranking anual.",
|
"Backend no disponible. No se puede consultar el ranking anual.",
|
||||||
searchNoInput: "Escribe un nombre o ID para buscar.",
|
searchNoInput: "Introduce al menos 4 caracteres para buscar un jugador.",
|
||||||
searchLoading: "Buscando jugadores...",
|
searchLoading: "Buscando jugadores...",
|
||||||
searchEmpty: "Sin resultados. Prueba con otro texto o ID.",
|
searchEmpty: "Sin resultados. Prueba con otro texto o ID.",
|
||||||
searchError:
|
searchError:
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
searchReadySuffix:
|
searchReadySuffix:
|
||||||
" jugador(es). Selecciona uno para ver sus estad\u00edsticas.",
|
" jugador(es). Selecciona uno para ver sus estad\u00edsticas.",
|
||||||
searchShortQueryHelp:
|
searchShortQueryHelp:
|
||||||
"Usa al menos 1 car\u00e1cter para iniciar la b\u00fasqueda.",
|
"Introduce al menos 4 caracteres para buscar un jugador.",
|
||||||
profileLoading: "Cargando estad\u00edsticas personales...",
|
profileLoading: "Cargando estad\u00edsticas personales...",
|
||||||
profileNoStats:
|
profileNoStats:
|
||||||
"Jugador sin estad\u00edsticas suficientes para mostrar datos personales.",
|
"Jugador sin estad\u00edsticas suficientes para mostrar datos personales.",
|
||||||
@@ -74,29 +74,24 @@
|
|||||||
|
|
||||||
if (searchHelpNode) {
|
if (searchHelpNode) {
|
||||||
searchHelpNode.textContent =
|
searchHelpNode.textContent =
|
||||||
"Usa el buscador para encontrar un jugador. Al seleccionar uno ver\u00e1s resumen semanal y mensual. " +
|
"Usa al menos 4 caracteres para encontrar un jugador. Al seleccionar uno ver\u00e1s resumen semanal y mensual. " +
|
||||||
"El ranking anual se consulta por separado con el a\u00f1o indicado.";
|
"El ranking anual visible corresponde a la temporada 2026.";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (annualYearInput) {
|
|
||||||
annualYearInput.value = String(annualDefaultYear);
|
|
||||||
}
|
|
||||||
clearProfilePanel(false);
|
clearProfilePanel(false);
|
||||||
|
syncSearchFormState();
|
||||||
refreshBackendHealth();
|
refreshBackendHealth();
|
||||||
|
|
||||||
if (annualForm && annualYearInput) {
|
|
||||||
annualForm.addEventListener("submit", (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
void loadAnnualRanking();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (searchForm && searchInput) {
|
if (searchForm && searchInput) {
|
||||||
|
searchInput.addEventListener("input", () => {
|
||||||
|
syncSearchFormState();
|
||||||
|
});
|
||||||
|
|
||||||
searchForm.addEventListener("submit", (event) => {
|
searchForm.addEventListener("submit", (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const query = (searchInput.value || "").trim();
|
const query = (searchInput.value || "").trim();
|
||||||
if (!query) {
|
if (!isValidSearchQuery(query)) {
|
||||||
setSearchState("error", messages.searchNoInput);
|
syncSearchFormState();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void searchPlayers(query);
|
void searchPlayers(query);
|
||||||
@@ -161,7 +156,7 @@
|
|||||||
isBackendOnline = true;
|
isBackendOnline = true;
|
||||||
setAnnualState(
|
setAnnualState(
|
||||||
"neutral",
|
"neutral",
|
||||||
"Backend disponible. Selecciona un a\u00f1o para cargar el ranking anual.",
|
"Backend disponible. Cargando ranking anual 2026.",
|
||||||
);
|
);
|
||||||
void loadAnnualRanking();
|
void loadAnnualRanking();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -174,7 +169,7 @@
|
|||||||
if (!resultListNode) {
|
if (!resultListNode) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!query) {
|
if (!isValidSearchQuery(query)) {
|
||||||
setSearchState("error", messages.searchShortQueryHelp);
|
setSearchState("error", messages.searchShortQueryHelp);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -227,7 +222,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadAnnualRanking() {
|
async function loadAnnualRanking() {
|
||||||
if (!annualForm || !annualYearInput || !annualContentNode) {
|
if (!annualContentNode) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!isBackendOnline) {
|
if (!isBackendOnline) {
|
||||||
@@ -235,11 +230,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const year = resolveAnnualYear();
|
const year = annualDefaultYear;
|
||||||
if (year === null) {
|
|
||||||
setAnnualState("error", "El a\u00f1o ingresado no es v\u00e1lido.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setAnnualState("loading", messages.annualLoading);
|
setAnnualState("loading", messages.annualLoading);
|
||||||
annualContentNode.innerHTML = "";
|
annualContentNode.innerHTML = "";
|
||||||
@@ -296,14 +287,6 @@
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveAnnualYear() {
|
|
||||||
const normalized = Number.parseInt(String(annualYearInput?.value || "").trim(), 10);
|
|
||||||
if (!Number.isFinite(normalized) || normalized <= 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderAnnualRanking(data) {
|
function renderAnnualRanking(data) {
|
||||||
if (!annualContentNode || !annualStateNode) {
|
if (!annualContentNode || !annualStateNode) {
|
||||||
return;
|
return;
|
||||||
@@ -369,8 +352,9 @@
|
|||||||
const rank = safeInt(item.ranking_position, 0);
|
const rank = safeInt(item.ranking_position, 0);
|
||||||
const playerId = escapeHtml(String(item.player_id || ""));
|
const playerId = escapeHtml(String(item.player_id || ""));
|
||||||
const playerName = escapeHtml(String(item.player_name || "Jugador sin nombre"));
|
const playerName = escapeHtml(String(item.player_name || "Jugador sin nombre"));
|
||||||
const metricValue = safeInt(item.metric_value, 0);
|
const kills = safeInt(firstFiniteValue(item.kills, item.metric_value), 0);
|
||||||
const matches = safeInt(item.matches_considered, 0);
|
const matches = safeInt(item.matches_considered, 0);
|
||||||
|
const kpm = formatKpm(item.kills_per_match, kills, matches);
|
||||||
const deaths = safeInt(item.deaths, 0);
|
const deaths = safeInt(item.deaths, 0);
|
||||||
const teamkills = safeInt(item.teamkills, 0);
|
const teamkills = safeInt(item.teamkills, 0);
|
||||||
const kd = safeDecimal(item.kd_ratio, 2, "0.00");
|
const kd = safeDecimal(item.kd_ratio, 2, "0.00");
|
||||||
@@ -384,8 +368,9 @@
|
|||||||
<span class="stats-annual-player__id">ID ${playerId}</span>
|
<span class="stats-annual-player__id">ID ${playerId}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="stats-annual-metric">${metricValue}</td>
|
<td class="stats-annual-metric">${kills}</td>
|
||||||
<td>${matches}</td>
|
<td>${matches}</td>
|
||||||
|
<td>${kpm}</td>
|
||||||
<td>${deaths}</td>
|
<td>${deaths}</td>
|
||||||
<td>${teamkills}</td>
|
<td>${teamkills}</td>
|
||||||
<td>${kd}</td>
|
<td>${kd}</td>
|
||||||
@@ -401,8 +386,9 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th>Posici\u00f3n</th>
|
<th>Posici\u00f3n</th>
|
||||||
<th>Jugador</th>
|
<th>Jugador</th>
|
||||||
<th>Valor / Kills</th>
|
<th>Kills</th>
|
||||||
<th>Partidas</th>
|
<th>Partidas</th>
|
||||||
|
<th>KPM</th>
|
||||||
<th>Muertes</th>
|
<th>Muertes</th>
|
||||||
<th>Teamkills</th>
|
<th>Teamkills</th>
|
||||||
<th>K/D</th>
|
<th>K/D</th>
|
||||||
@@ -891,6 +877,31 @@
|
|||||||
return Number.isFinite(parsed) ? parsed : Number.NaN;
|
return Number.isFinite(parsed) ? parsed : Number.NaN;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function firstFiniteValue(...values) {
|
||||||
|
for (const value of values) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (Number.isFinite(parsed)) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Number.NaN;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatKpm(rawKillsPerMatch, rawKills, rawMatches) {
|
||||||
|
const directValue = safeParseNumber(rawKillsPerMatch);
|
||||||
|
if (Number.isFinite(directValue)) {
|
||||||
|
return safeDecimal(directValue, 2, "0.00");
|
||||||
|
}
|
||||||
|
|
||||||
|
const kills = safeParseNumber(rawKills);
|
||||||
|
const matches = safeParseNumber(rawMatches);
|
||||||
|
if (!Number.isFinite(kills) || !Number.isFinite(matches) || matches <= 0) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
return safeDecimal(kills / matches, 2, "0.00");
|
||||||
|
}
|
||||||
|
|
||||||
function safeParseJson(response) {
|
function safeParseJson(response) {
|
||||||
if (!response) {
|
if (!response) {
|
||||||
return null;
|
return null;
|
||||||
@@ -920,6 +931,35 @@
|
|||||||
return Array.isArray(items) ? items : [];
|
return Array.isArray(items) ? items : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isValidSearchQuery(query) {
|
||||||
|
return String(query || "").trim().length >= minimumSearchQueryLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncSearchFormState() {
|
||||||
|
const query = String(searchInput?.value || "").trim();
|
||||||
|
const isValid = isValidSearchQuery(query);
|
||||||
|
|
||||||
|
if (searchSubmitButton) {
|
||||||
|
searchSubmitButton.disabled = !isValid;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!searchStateNode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!query) {
|
||||||
|
setSearchState(
|
||||||
|
"neutral",
|
||||||
|
"Estado inicial: introduce al menos 4 caracteres para buscar un jugador.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
setSearchState("warning", messages.searchShortQueryHelp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function escapeHtml(value) {
|
function escapeHtml(value) {
|
||||||
return String(value)
|
return String(value)
|
||||||
.replaceAll("&", "&")
|
.replaceAll("&", "&")
|
||||||
|
|||||||
@@ -199,6 +199,7 @@
|
|||||||
<th>Jugador</th>
|
<th>Jugador</th>
|
||||||
<th id="weekly-leaderboard-value-heading">Kills</th>
|
<th id="weekly-leaderboard-value-heading">Kills</th>
|
||||||
<th>Partidas</th>
|
<th>Partidas</th>
|
||||||
|
<th>KPM</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="weekly-leaderboard-body"></tbody>
|
<tbody id="weekly-leaderboard-body"></tbody>
|
||||||
|
|||||||
@@ -90,7 +90,7 @@
|
|||||||
<option value="teamkills">Teamkills</option>
|
<option value="teamkills">Teamkills</option>
|
||||||
<option value="matches_considered">Partidas jugadas</option>
|
<option value="matches_considered">Partidas jugadas</option>
|
||||||
<option value="kd_ratio">K/D</option>
|
<option value="kd_ratio">K/D</option>
|
||||||
<option value="kills_per_match">Kills por partida</option>
|
<option value="kills_per_match">KPM</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label class="stats-search-form__label" for="ranking-limit">
|
<label class="stats-search-form__label" for="ranking-limit">
|
||||||
@@ -152,7 +152,7 @@
|
|||||||
<th>Teamkills</th>
|
<th>Teamkills</th>
|
||||||
<th>Partidas</th>
|
<th>Partidas</th>
|
||||||
<th>K/D</th>
|
<th>K/D</th>
|
||||||
<th>Kills/partida</th>
|
<th id="ranking-kpm-heading">KPM</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="ranking-table-body"></tbody>
|
<tbody id="ranking-table-body"></tbody>
|
||||||
|
|||||||
@@ -62,7 +62,7 @@
|
|||||||
<h2>Buscar por nombre o ID</h2>
|
<h2>Buscar por nombre o ID</h2>
|
||||||
</div>
|
</div>
|
||||||
<p class="panel__intro panel__intro--tight" id="stats-search-help">
|
<p class="panel__intro panel__intro--tight" id="stats-search-help">
|
||||||
Usa al menos 1 carácter para buscar. Al seleccionar un jugador se cargarán sus
|
Usa al menos 4 caracteres para buscar. Al seleccionar un jugador se cargarán sus
|
||||||
stats y ranking semanal y mensual. El ranking anual se consulta por separado.
|
stats y ranking semanal y mensual. El ranking anual se consulta por separado.
|
||||||
</p>
|
</p>
|
||||||
<form class="stats-search-form" id="stats-search-form">
|
<form class="stats-search-form" id="stats-search-form">
|
||||||
@@ -79,7 +79,13 @@
|
|||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
aria-label="Texto de búsqueda de jugadores"
|
aria-label="Texto de búsqueda de jugadores"
|
||||||
/>
|
/>
|
||||||
<button class="discord-button" type="submit" aria-label="Buscar jugador por nombre o ID">
|
<button
|
||||||
|
class="discord-button"
|
||||||
|
id="stats-search-submit"
|
||||||
|
type="submit"
|
||||||
|
aria-label="Buscar jugador por nombre o ID"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
Buscar jugador
|
Buscar jugador
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -123,30 +129,17 @@
|
|||||||
<div class="panel__shell">
|
<div class="panel__shell">
|
||||||
<div class="panel__header">
|
<div class="panel__header">
|
||||||
<p class="eyebrow eyebrow--section">Ranking anual</p>
|
<p class="eyebrow eyebrow--section">Ranking anual</p>
|
||||||
<h2>Top 20 anual</h2>
|
<h2>Top 20 temporada 2026</h2>
|
||||||
</div>
|
</div>
|
||||||
<p class="panel__intro panel__intro--tight">
|
<p class="panel__intro panel__intro--tight">
|
||||||
El ranking anual usa un corte ya preparado de la temporada para cargar el Top 20 sin recalcular toda la campaña en cada consulta.
|
El ranking anual usa un corte ya preparado de la temporada para cargar el Top 20 sin recalcular toda la campaña en cada consulta.
|
||||||
</p>
|
</p>
|
||||||
<form class="stats-annual-form" id="stats-annual-form">
|
<div class="stats-annual-season" aria-label="Temporada fija del ranking anual">
|
||||||
<label class="stats-search-form__label" for="stats-annual-year">Año</label>
|
<span class="stats-annual-season__label">Temporada</span>
|
||||||
<div class="stats-search-form__fields">
|
<strong class="stats-annual-season__value">2026</strong>
|
||||||
<input
|
|
||||||
class="stats-search-input"
|
|
||||||
id="stats-annual-year"
|
|
||||||
type="number"
|
|
||||||
min="2019"
|
|
||||||
value=""
|
|
||||||
aria-label="Año del ranking anual"
|
|
||||||
aria-describedby="stats-annual-state"
|
|
||||||
/>
|
|
||||||
<button class="discord-button" type="submit" aria-label="Cargar ranking anual">
|
|
||||||
Cargar ranking anual
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
|
||||||
<p class="stats-state stats-state--neutral" id="stats-annual-state" role="status" aria-live="polite">
|
<p class="stats-state stats-state--neutral" id="stats-annual-state" role="status" aria-live="polite">
|
||||||
Esperando año para cargar el ranking anual (Top 20 por kills).
|
Preparando ranking anual 2026 (Top 20 por kills).
|
||||||
</p>
|
</p>
|
||||||
<div class="stats-annual-table-wrap" id="stats-annual-content" aria-live="polite"></div>
|
<div class="stats-annual-table-wrap" id="stats-annual-content" aria-live="polite"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user