Complete Stats validation, comparison cards, and annual ranking hardening
This commit is contained in:
@@ -1069,6 +1069,123 @@ h2 {
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr));
|
||||
}
|
||||
|
||||
.stats-comparison-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin: 16px 0;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr));
|
||||
}
|
||||
|
||||
.stats-comparison-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-height: 100%;
|
||||
border: 1px solid rgba(183, 201, 125, 0.18);
|
||||
border-radius: 18px;
|
||||
padding: 16px 18px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(30, 36, 27, 0.94), rgba(11, 14, 10, 0.98));
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.03),
|
||||
var(--shadow-soft);
|
||||
}
|
||||
|
||||
.stats-comparison-card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0 0 auto;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, rgba(210, 182, 118, 0.5), transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.stats-comparison-card__eyebrow {
|
||||
margin: 0 0 8px;
|
||||
color: var(--accent-warm);
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.stats-comparison-card__title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 1rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.stats-comparison-card__badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 34px;
|
||||
margin-bottom: 12px;
|
||||
padding: 0.4rem 0.8rem;
|
||||
border: 1px solid rgba(159, 168, 141, 0.22);
|
||||
border-radius: 999px;
|
||||
background: rgba(15, 18, 13, 0.84);
|
||||
color: var(--text-soft);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.stats-comparison-card__badge--ok {
|
||||
border-color: rgba(183, 201, 125, 0.34);
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
|
||||
.stats-comparison-card__badge--warning {
|
||||
border-color: rgba(210, 182, 118, 0.32);
|
||||
color: #e2d7a9;
|
||||
}
|
||||
|
||||
.stats-comparison-card__badge--error {
|
||||
border-color: rgba(210, 182, 118, 0.45);
|
||||
color: var(--accent-warm);
|
||||
}
|
||||
|
||||
.stats-comparison-card__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.stats-comparison-card__metric {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid rgba(159, 168, 141, 0.14);
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(180deg, rgba(15, 18, 13, 0.5), rgba(8, 10, 7, 0.3));
|
||||
}
|
||||
|
||||
.stats-comparison-card__metric-label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 0.74rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.stats-comparison-card__metric-value {
|
||||
display: block;
|
||||
color: var(--text);
|
||||
font-size: 1.02rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.stats-comparison-card__detail,
|
||||
.stats-comparison-card__note {
|
||||
margin: 0;
|
||||
color: var(--text-soft);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.stats-comparison-card__detail + .stats-comparison-card__detail,
|
||||
.stats-comparison-card__detail + .stats-comparison-card__note {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.stats-summary-card {
|
||||
margin: 0;
|
||||
border: 1px solid rgba(159, 168, 141, 0.2);
|
||||
@@ -1171,6 +1288,10 @@ h2 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.stats-comparison-card__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.stats-search-form__fields .discord-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
const profilePanel = document.getElementById("stats-profile-panel");
|
||||
const profileTitle = document.getElementById("stats-profile-title");
|
||||
const profileStateNode = document.getElementById("stats-profile-state");
|
||||
const comparisonGrid = document.getElementById("stats-comparison-grid");
|
||||
const summaryGrid = document.getElementById("stats-summary-grid");
|
||||
const weeklySummaryNode = document.getElementById("stats-weekly-summary");
|
||||
const monthlySummaryNode = document.getElementById("stats-monthly-summary");
|
||||
@@ -143,6 +144,9 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
profilePanel.hidden = false;
|
||||
profileStateNode.textContent = message;
|
||||
profileStateNode.className = `stats-state stats-state--${state}`;
|
||||
if (comparisonGrid) {
|
||||
comparisonGrid.innerHTML = "";
|
||||
}
|
||||
summaryGrid.innerHTML = "";
|
||||
if (weeklySummaryNode) {
|
||||
weeklySummaryNode.textContent = "Cargando ...";
|
||||
@@ -423,24 +427,17 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${backendBaseUrl}/api/stats/players/${encodeURIComponent(playerId)}?timeframe=weekly`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Profile request failed with ${response.status}`);
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
if (!payload || payload.status !== "ok") {
|
||||
throw new Error(payload?.message || "Respuesta de perfil invalida");
|
||||
}
|
||||
|
||||
const data = payload.data || {};
|
||||
const [weeklyData, monthlyData] = await Promise.all([
|
||||
fetchPlayerProfile(playerId, "weekly"),
|
||||
fetchPlayerProfile(playerId, "monthly"),
|
||||
]);
|
||||
profilePanel.hidden = false;
|
||||
profileTitle.textContent = messages.profileReadyTitle;
|
||||
const playerName = String(data.player_name || "Sin nombre");
|
||||
const playerIdText = String(data.player_id || playerId);
|
||||
const hasStats = Number(data.matches_considered || 0) > 0;
|
||||
const playerName = String(weeklyData.player_name || monthlyData.player_name || "Sin nombre");
|
||||
const playerIdText = String(weeklyData.player_id || monthlyData.player_id || playerId);
|
||||
const hasWeeklyStats = Number(weeklyData.matches_considered || 0) > 0;
|
||||
const hasMonthlyStats = Number(monthlyData.matches_considered || 0) > 0;
|
||||
const hasStats = hasWeeklyStats || hasMonthlyStats;
|
||||
|
||||
profileStateNode.textContent = hasStats
|
||||
? `${playerName} (${playerIdText})`
|
||||
@@ -454,29 +451,36 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
<p class="stats-summary-title">Identidad</p>
|
||||
<p><strong>Jugador:</strong> ${escapeHtml(playerName)}</p>
|
||||
<p><strong>ID:</strong> ${escapeHtml(playerIdText)}</p>
|
||||
<p><strong>Partidas:</strong> ${safeInt(data.matches_considered, 0)}</p>
|
||||
<p><strong>Partidas semanales:</strong> ${safeInt(weeklyData.matches_considered, 0)}</p>
|
||||
<p><strong>Partidas mensuales:</strong> ${safeInt(monthlyData.matches_considered, 0)}</p>
|
||||
</article>
|
||||
<article class="stats-summary-card">
|
||||
<p class="stats-summary-title">Totales</p>
|
||||
<p><strong>Kills:</strong> ${safeInt(data.kills, 0)}</p>
|
||||
<p><strong>Deaths:</strong> ${safeInt(data.deaths, 0)}</p>
|
||||
<p><strong>Teamkills:</strong> ${safeInt(data.teamkills, 0)}</p>
|
||||
<p class="stats-summary-title">Ventana semanal</p>
|
||||
<p><strong>Kills:</strong> ${safeInt(weeklyData.kills, 0)}</p>
|
||||
<p><strong>Deaths:</strong> ${safeInt(weeklyData.deaths, 0)}</p>
|
||||
<p><strong>Teamkills:</strong> ${safeInt(weeklyData.teamkills, 0)}</p>
|
||||
</article>
|
||||
<article class="stats-summary-card">
|
||||
<p class="stats-summary-title">Ratios</p>
|
||||
<p><strong>K/D:</strong> ${safeDecimal(data.kd_ratio, 2, "0.00")}</p>
|
||||
<p><strong>Kills/partida:</strong> ${safeDecimal(data.kills_per_match, 2, "0.00")}</p>
|
||||
<p><strong>Deaths/partida:</strong> ${safeDecimal(data.deaths_per_match, 2, "0.00")}</p>
|
||||
<p class="stats-summary-title">Ventana mensual</p>
|
||||
<p><strong>Kills:</strong> ${safeInt(monthlyData.kills, 0)}</p>
|
||||
<p><strong>Deaths:</strong> ${safeInt(monthlyData.deaths, 0)}</p>
|
||||
<p><strong>Teamkills:</strong> ${safeInt(monthlyData.teamkills, 0)}</p>
|
||||
</article>
|
||||
`;
|
||||
|
||||
if (comparisonGrid) {
|
||||
comparisonGrid.innerHTML = renderComparisonCards(weeklyData, monthlyData);
|
||||
}
|
||||
|
||||
weeklySummaryNode.innerHTML = formatRankingSummary(
|
||||
data.weekly_ranking,
|
||||
weeklyData.weekly_ranking,
|
||||
"Semanal",
|
||||
weeklyData.matches_considered,
|
||||
);
|
||||
monthlySummaryNode.innerHTML = formatRankingSummary(
|
||||
data.monthly_ranking,
|
||||
monthlyData.monthly_ranking,
|
||||
"Mensual",
|
||||
monthlyData.matches_considered,
|
||||
);
|
||||
|
||||
setBackendState(messages.backendOnline, true);
|
||||
@@ -490,6 +494,9 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
profileTitle.textContent = messages.profileReadyTitle;
|
||||
profileStateNode.textContent = messages.profileError;
|
||||
profileStateNode.className = "stats-state stats-state--error";
|
||||
if (comparisonGrid) {
|
||||
comparisonGrid.innerHTML = "";
|
||||
}
|
||||
summaryGrid.innerHTML = "";
|
||||
if (weeklySummaryNode) {
|
||||
weeklySummaryNode.textContent = messages.weeklyPlaceholder;
|
||||
@@ -507,6 +514,9 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
if (openPanel) {
|
||||
profilePanel.hidden = true;
|
||||
}
|
||||
if (comparisonGrid) {
|
||||
comparisonGrid.innerHTML = "";
|
||||
}
|
||||
summaryGrid.innerHTML = "";
|
||||
if (weeklySummaryNode) {
|
||||
weeklySummaryNode.textContent = messages.weeklyPlaceholder;
|
||||
@@ -518,27 +528,213 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
profileStateNode.className = "stats-state stats-state--neutral";
|
||||
}
|
||||
|
||||
function formatRankingSummary(ranking, timeframeLabel) {
|
||||
if (!ranking || !Number.isFinite(safeParseNumber(ranking.ranking_position))) {
|
||||
return `Sin ranking ${timeframeLabel.toLowerCase()} registrado para este jugador.`;
|
||||
async function fetchPlayerProfile(playerId, timeframe) {
|
||||
const response = await fetch(
|
||||
`${backendBaseUrl}/api/stats/players/${encodeURIComponent(playerId)}?timeframe=${encodeURIComponent(timeframe)}`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Profile request failed with ${response.status}`);
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
if (!payload || payload.status !== "ok") {
|
||||
throw new Error(payload?.message || "Respuesta de perfil invalida");
|
||||
}
|
||||
|
||||
return payload.data || {};
|
||||
}
|
||||
|
||||
function renderComparisonCards(weeklyData, monthlyData) {
|
||||
return [
|
||||
renderWindowComparisonCard("Semanal", weeklyData, weeklyData.weekly_ranking),
|
||||
renderWindowComparisonCard("Mensual", monthlyData, monthlyData.monthly_ranking),
|
||||
renderDeltaComparisonCard(weeklyData, monthlyData),
|
||||
].join("");
|
||||
}
|
||||
|
||||
function renderWindowComparisonCard(label, data, ranking) {
|
||||
const rankingState = describeRankingState(ranking, data?.matches_considered, label);
|
||||
const matches = safeInt(data?.matches_considered, 0);
|
||||
const kills = safeInt(data?.kills, 0);
|
||||
const deaths = safeInt(data?.deaths, 0);
|
||||
const kdRatio = safeDecimal(data?.kd_ratio, 2, "0.00");
|
||||
const killsPerMatch = safeDecimal(data?.kills_per_match, 2, "0.00");
|
||||
|
||||
return `
|
||||
<article class="stats-comparison-card">
|
||||
<p class="stats-comparison-card__eyebrow">Comparativa ${label.toLowerCase()}</p>
|
||||
<h3 class="stats-comparison-card__title">Ventana ${label.toLowerCase()}</h3>
|
||||
<span class="stats-comparison-card__badge stats-comparison-card__badge--${rankingState.tone}">
|
||||
${escapeHtml(rankingState.title)}
|
||||
</span>
|
||||
<div class="stats-comparison-card__grid">
|
||||
<div class="stats-comparison-card__metric">
|
||||
<span class="stats-comparison-card__metric-label">Partidas</span>
|
||||
<span class="stats-comparison-card__metric-value">${matches}</span>
|
||||
</div>
|
||||
<div class="stats-comparison-card__metric">
|
||||
<span class="stats-comparison-card__metric-label">Kills</span>
|
||||
<span class="stats-comparison-card__metric-value">${kills}</span>
|
||||
</div>
|
||||
<div class="stats-comparison-card__metric">
|
||||
<span class="stats-comparison-card__metric-label">Deaths</span>
|
||||
<span class="stats-comparison-card__metric-value">${deaths}</span>
|
||||
</div>
|
||||
<div class="stats-comparison-card__metric">
|
||||
<span class="stats-comparison-card__metric-label">K/D</span>
|
||||
<span class="stats-comparison-card__metric-value">${kdRatio}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="stats-comparison-card__detail">
|
||||
<strong>Kills por partida:</strong> ${killsPerMatch}
|
||||
</p>
|
||||
<p class="stats-comparison-card__note">${escapeHtml(rankingState.detail)}</p>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderDeltaComparisonCard(weeklyData, monthlyData) {
|
||||
const weeklyKpm = safeParseNumber(weeklyData?.kills_per_match);
|
||||
const monthlyKpm = safeParseNumber(monthlyData?.kills_per_match);
|
||||
const weeklyKd = safeParseNumber(weeklyData?.kd_ratio);
|
||||
const monthlyKd = safeParseNumber(monthlyData?.kd_ratio);
|
||||
const killsDelta = safeInt(monthlyData?.kills, 0) - safeInt(weeklyData?.kills, 0);
|
||||
const matchesDelta =
|
||||
safeInt(monthlyData?.matches_considered, 0) - safeInt(weeklyData?.matches_considered, 0);
|
||||
|
||||
return `
|
||||
<article class="stats-comparison-card">
|
||||
<p class="stats-comparison-card__eyebrow">Lectura comparada</p>
|
||||
<h3 class="stats-comparison-card__title">Pulso semanal vs mensual</h3>
|
||||
<span class="stats-comparison-card__badge stats-comparison-card__badge--ok">
|
||||
${escapeHtml(buildDeltaHeadline(killsDelta, matchesDelta))}
|
||||
</span>
|
||||
<div class="stats-comparison-card__grid">
|
||||
<div class="stats-comparison-card__metric">
|
||||
<span class="stats-comparison-card__metric-label">KPM semanal</span>
|
||||
<span class="stats-comparison-card__metric-value">${safeDecimal(weeklyKpm, 2, "0.00")}</span>
|
||||
</div>
|
||||
<div class="stats-comparison-card__metric">
|
||||
<span class="stats-comparison-card__metric-label">KPM mensual</span>
|
||||
<span class="stats-comparison-card__metric-value">${safeDecimal(monthlyKpm, 2, "0.00")}</span>
|
||||
</div>
|
||||
<div class="stats-comparison-card__metric">
|
||||
<span class="stats-comparison-card__metric-label">K/D semanal</span>
|
||||
<span class="stats-comparison-card__metric-value">${safeDecimal(weeklyKd, 2, "0.00")}</span>
|
||||
</div>
|
||||
<div class="stats-comparison-card__metric">
|
||||
<span class="stats-comparison-card__metric-label">K/D mensual</span>
|
||||
<span class="stats-comparison-card__metric-value">${safeDecimal(monthlyKd, 2, "0.00")}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="stats-comparison-card__detail">
|
||||
<strong>Delta de kills:</strong> ${formatSignedNumber(killsDelta)}
|
||||
</p>
|
||||
<p class="stats-comparison-card__detail">
|
||||
<strong>Delta de partidas:</strong> ${formatSignedNumber(matchesDelta)}
|
||||
</p>
|
||||
<p class="stats-comparison-card__note">
|
||||
La lectura compara solo datos existentes del backend actual, sin recalcular rankings ni pedir nuevos endpoints.
|
||||
</p>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
function formatRankingSummary(ranking, timeframeLabel, matchesConsidered) {
|
||||
const rankingState = describeRankingState(ranking, matchesConsidered, timeframeLabel);
|
||||
if (!ranking) {
|
||||
return escapeHtml(rankingState.detail);
|
||||
}
|
||||
|
||||
if (!Number.isFinite(safeParseNumber(ranking.ranking_position))) {
|
||||
return `
|
||||
<p><strong>Estado:</strong> ${escapeHtml(rankingState.title)}</p>
|
||||
<p>${escapeHtml(rankingState.detail)}</p>
|
||||
<p>${escapeHtml(formatWindowRange(ranking))}</p>
|
||||
`;
|
||||
}
|
||||
|
||||
const rank = safeInt(ranking.ranking_position, 0);
|
||||
const metric = escapeHtml(String(ranking.metric || annualMetric));
|
||||
const windowLabel = escapeHtml(String(ranking.window_kind || ""));
|
||||
const windowStart = formatDateTime(ranking.window_start);
|
||||
const windowEnd = formatDateTime(ranking.window_end);
|
||||
const windowRange = windowLabel
|
||||
? `${windowLabel}: ${windowStart} - ${windowEnd}`
|
||||
: `Ventana: ${windowStart} - ${windowEnd}`;
|
||||
|
||||
return `
|
||||
<p><strong>Posicion:</strong> ${rank}</p>
|
||||
<p><strong>Metric:</strong> ${metric}</p>
|
||||
<p>${windowRange}</p>
|
||||
<p>${escapeHtml(formatWindowRange(ranking))}</p>
|
||||
`;
|
||||
}
|
||||
|
||||
function describeRankingState(ranking, matchesConsidered, timeframeLabel) {
|
||||
const matches = safeInt(matchesConsidered, 0);
|
||||
const label = String(timeframeLabel || "ranking").toLowerCase();
|
||||
if (!ranking) {
|
||||
return {
|
||||
tone: "error",
|
||||
title: "Ranking ausente",
|
||||
detail: `No se recibio bloque de ranking ${label} desde el backend.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (Number.isFinite(safeParseNumber(ranking.ranking_position))) {
|
||||
return {
|
||||
tone: "ok",
|
||||
title: `Posicion #${safeInt(ranking.ranking_position, 0)}`,
|
||||
detail: `Jugador posicionado en el ranking ${label} activo por kills.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (matches <= 0) {
|
||||
return {
|
||||
tone: "warning",
|
||||
title: "Sin actividad",
|
||||
detail: `No hay actividad cerrada del jugador en la ventana ${label} actual.`,
|
||||
};
|
||||
}
|
||||
|
||||
const windowKind = String(ranking.window_kind || "").toLowerCase();
|
||||
if (windowKind.startsWith("previous-")) {
|
||||
return {
|
||||
tone: "warning",
|
||||
title: "Profundidad insuficiente",
|
||||
detail: `La ventana ${label} activa usa fallback previo y el jugador no aparece posicionado.`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
tone: "warning",
|
||||
title: "Fuera del ranking visible",
|
||||
detail: `El jugador tiene actividad en la ventana ${label}, pero no figura en el ranking visible actual.`,
|
||||
};
|
||||
}
|
||||
|
||||
function buildDeltaHeadline(killsDelta, matchesDelta) {
|
||||
if (killsDelta === 0 && matchesDelta === 0) {
|
||||
return "Ritmo estable";
|
||||
}
|
||||
if (killsDelta > 0 || matchesDelta > 0) {
|
||||
return "Presion mensual superior";
|
||||
}
|
||||
return "Ventana corta mas intensa";
|
||||
}
|
||||
|
||||
function formatSignedNumber(value) {
|
||||
const parsed = safeInt(value, 0);
|
||||
if (parsed > 0) {
|
||||
return `+${parsed}`;
|
||||
}
|
||||
return String(parsed);
|
||||
}
|
||||
|
||||
function formatWindowRange(ranking) {
|
||||
const windowLabel = String(ranking?.window_kind || "").trim();
|
||||
const windowStart = formatDateTime(ranking?.window_start);
|
||||
const windowEnd = formatDateTime(ranking?.window_end);
|
||||
if (windowLabel) {
|
||||
return `${windowLabel}: ${windowStart} - ${windowEnd}`;
|
||||
}
|
||||
return `Ventana: ${windowStart} - ${windowEnd}`;
|
||||
}
|
||||
|
||||
function safeInt(value, fallback = 0) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
|
||||
@@ -86,6 +86,7 @@
|
||||
<p class="panel__intro panel__intro--tight" id="stats-profile-state">
|
||||
Selecciona un jugador para ver su panel personal.
|
||||
</p>
|
||||
<div class="stats-comparison-grid" id="stats-comparison-grid"></div>
|
||||
<div class="stats-summary-grid" id="stats-summary-grid"></div>
|
||||
<div class="stats-ranking-grid">
|
||||
<article class="stats-summary-card">
|
||||
|
||||
Reference in New Issue
Block a user