Add real KPM to historical detail table and fix kills per match labels
This commit is contained in:
@@ -0,0 +1,87 @@
|
|||||||
|
# TASK-248 - Add real KPM to public tables and fix kills per match labels
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
This task extends the already validated real historical KPM to the main player table in `historico-partida.html` and removes misleading public `KPM` labels that were actually rendering `kills_per_match`.
|
||||||
|
|
||||||
|
It does not change the real KPM formula already validated in production.
|
||||||
|
|
||||||
|
## Files Read First
|
||||||
|
|
||||||
|
- `frontend/historico-partida.html`
|
||||||
|
- `frontend/assets/js/historico-partida.js`
|
||||||
|
- `frontend/assets/css/historico.css`
|
||||||
|
- `frontend/historico.html`
|
||||||
|
- `frontend/assets/js/historico.js`
|
||||||
|
- `frontend/stats.html`
|
||||||
|
- `frontend/assets/js/stats.js`
|
||||||
|
- `frontend/ranking.html`
|
||||||
|
- `frontend/assets/js/ranking.js`
|
||||||
|
- `backend/app/rcon_historical_read_model.py`
|
||||||
|
- `docs/HISTORICAL_MATCH_KILLS_PER_MINUTE_ANALYSIS.md`
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The repository already exposed real KPM at player level in `/api/historical/matches/detail`, but the public UI still had two problems:
|
||||||
|
|
||||||
|
1. `historico-partida` only showed real KPM in the expanded player panel, not in the main table.
|
||||||
|
2. Several public surfaces were calling `kills_per_match` by the visible label `KPM`, which is incorrect.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Keep the validated real KPM model unchanged:
|
||||||
|
|
||||||
|
- only `kpm_status = ready` renders a visible value
|
||||||
|
- no total-match-duration fallback
|
||||||
|
- no `event_span_fallback` as ready KPM
|
||||||
|
|
||||||
|
Apply two UI rules:
|
||||||
|
|
||||||
|
- historical match detail main table gets a real `KPM` column
|
||||||
|
- any visible `kills_per_match` label becomes `Kills/partida`
|
||||||
|
|
||||||
|
Do not add public aggregated weekly/monthly/annual real KPM yet. That would need a separate backend and snapshot coverage contract so mixed datasets do not produce misleading results.
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
### Historical match detail
|
||||||
|
|
||||||
|
- Added a sortable `KPM` option in the player table.
|
||||||
|
- Added a `KPM` column in the main player list.
|
||||||
|
- The cell stays empty unless `kpm_status == "ready"`.
|
||||||
|
- The expanded panel keeps its existing real KPM chip.
|
||||||
|
|
||||||
|
### Historical weekly/monthly tables
|
||||||
|
|
||||||
|
- Renamed the last leaderboard metric from `KPM` to `Kills/partida`.
|
||||||
|
- Kept the underlying `kills_per_match` meaning unchanged.
|
||||||
|
|
||||||
|
### Stats and ranking
|
||||||
|
|
||||||
|
- Annual stats table now labels `kills_per_match` as `Kills/partida`.
|
||||||
|
- Weekly/monthly comparison cards now label the same metric as `Kills/partida semanal` and `Kills/partida mensual`.
|
||||||
|
- Ranking already used `Kills/partida`, so no semantic rename was needed there.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
Executed:
|
||||||
|
|
||||||
|
- `node --check frontend/assets/js/historico-partida.js`
|
||||||
|
- `node --check frontend/assets/js/historico.js`
|
||||||
|
- `node --check frontend/assets/js/stats.js`
|
||||||
|
- `node --check frontend/assets/js/ranking.js`
|
||||||
|
|
||||||
|
Static validation performed:
|
||||||
|
|
||||||
|
- confirmed `historico-partida.html` main table now shows `KPM`
|
||||||
|
- confirmed expanded player panel still shows real KPM
|
||||||
|
- confirmed rows with `insufficient_active_time` keep the main table cell empty
|
||||||
|
- confirmed historical weekly/monthly tables no longer show `KPM` for `kills_per_match`
|
||||||
|
- confirmed annual stats no longer show `KPM` for `kills_per_match`
|
||||||
|
- confirmed ranking still uses `Kills/partida`
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- No backend changes were required for this task.
|
||||||
|
- No scheduler, RCON or server configuration changes were made.
|
||||||
|
- Public aggregated real KPM for weekly/monthly/annual remains pending a dedicated coverage-safe snapshot design.
|
||||||
@@ -159,6 +159,55 @@ For those rows:
|
|||||||
|
|
||||||
If the value is missing or below threshold, the panel stays clean and does not show a fake metric.
|
If the value is missing or below threshold, the panel stays clean and does not show a fake metric.
|
||||||
|
|
||||||
|
## Public Tables
|
||||||
|
|
||||||
|
The historical match detail now exposes the same real KPM in two public places:
|
||||||
|
|
||||||
|
- expanded player panel in `historico-partida.html`
|
||||||
|
- main players table in `historico-partida.html`
|
||||||
|
|
||||||
|
The main table leaves the KPM cell empty when `kpm_status != "ready"`.
|
||||||
|
|
||||||
|
This avoids false `0.00` values for:
|
||||||
|
|
||||||
|
- legacy rows without active time
|
||||||
|
- rows below `HLL_KPM_MIN_ACTIVE_SECONDS`
|
||||||
|
- rows that only expose `event_span_fallback`
|
||||||
|
|
||||||
|
## Kills Per Match Labels
|
||||||
|
|
||||||
|
Several public tables were showing `kills_per_match` under the visible label `KPM`.
|
||||||
|
|
||||||
|
That is no longer acceptable because:
|
||||||
|
|
||||||
|
- `kills_per_match` means kills divided by matches considered
|
||||||
|
- real KPM means kills divided by active minutes from reliable connection intervals
|
||||||
|
|
||||||
|
Public surfaces that now use `Kills/partida` instead of `KPM` for `kills_per_match`:
|
||||||
|
|
||||||
|
- historical weekly and monthly leaderboard tables
|
||||||
|
- annual stats summary table
|
||||||
|
- annual stats comparison cards
|
||||||
|
- ranking metric selector and ranking table label
|
||||||
|
|
||||||
|
## Aggregated Real KPM
|
||||||
|
|
||||||
|
This repository does not yet expose a public weekly, monthly or annual aggregate KPM based only on:
|
||||||
|
|
||||||
|
- `active_time_source = connection_intervals`
|
||||||
|
- `active_time_source = connection_intervals_carryover`
|
||||||
|
|
||||||
|
That was intentionally left out of this change because the public leaderboard and ranking snapshots would need an explicit coverage contract before mixing:
|
||||||
|
|
||||||
|
- rows with real active time
|
||||||
|
- older rows without it
|
||||||
|
- fallback rows blocked from `kpm_status = ready`
|
||||||
|
|
||||||
|
Until that contract exists, public aggregated views keep:
|
||||||
|
|
||||||
|
- `Kills/partida` for `kills_per_match`
|
||||||
|
- real KPM only at historical match detail player level
|
||||||
|
|
||||||
## Limitations
|
## Limitations
|
||||||
|
|
||||||
- This is observed active time from AdminLog connection evidence, not exact join/leave telemetry for every silent second.
|
- This is observed active time from AdminLog connection evidence, not exact join/leave telemetry for every silent second.
|
||||||
|
|||||||
@@ -701,7 +701,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.historical-table--players {
|
.historical-table--players {
|
||||||
min-width: 920px;
|
min-width: 1000px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.historical-table__position {
|
.historical-table__position {
|
||||||
|
|||||||
@@ -340,6 +340,7 @@ function getVisiblePlayers(players, item, state) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function comparePlayerEntries(a, b, item, state) {
|
function comparePlayerEntries(a, b, item, state) {
|
||||||
|
void item;
|
||||||
if (state.isDefaultSort) {
|
if (state.isDefaultSort) {
|
||||||
return (
|
return (
|
||||||
compareInactivePriority(a, b) ||
|
compareInactivePriority(a, b) ||
|
||||||
@@ -349,6 +350,19 @@ function comparePlayerEntries(a, b, item, state) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (state.sort === "kpm") {
|
||||||
|
const readyPriority = compareReadyKpmPriority(a.player, b.player);
|
||||||
|
if (readyPriority) {
|
||||||
|
return readyPriority;
|
||||||
|
}
|
||||||
|
const direction = state.direction === "asc" ? 1 : -1;
|
||||||
|
const compared = compareNumericStat(
|
||||||
|
getReadyKpmValue(a.player),
|
||||||
|
getReadyKpmValue(b.player),
|
||||||
|
);
|
||||||
|
return compared * direction || comparePlayerNames(a.player, b.player);
|
||||||
|
}
|
||||||
|
|
||||||
if (!["name", "team"].includes(state.sort)) {
|
if (!["name", "team"].includes(state.sort)) {
|
||||||
const inactivePriority = compareInactivePriority(a, b);
|
const inactivePriority = compareInactivePriority(a, b);
|
||||||
if (inactivePriority) {
|
if (inactivePriority) {
|
||||||
@@ -362,6 +376,7 @@ function comparePlayerEntries(a, b, item, state) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function comparePlayerSortValue(a, b, item, sort) {
|
function comparePlayerSortValue(a, b, item, sort) {
|
||||||
|
void item;
|
||||||
if (sort === "name") {
|
if (sort === "name") {
|
||||||
return comparePlayerNames(a.player, b.player);
|
return comparePlayerNames(a.player, b.player);
|
||||||
}
|
}
|
||||||
@@ -377,6 +392,15 @@ function comparePlayerSortValue(a, b, item, sort) {
|
|||||||
return compareNumericStat(a.player.kills, b.player.kills);
|
return compareNumericStat(a.player.kills, b.player.kills);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function compareReadyKpmPriority(leftPlayer, rightPlayer) {
|
||||||
|
const leftReady = leftPlayer?.kpm_status === "ready";
|
||||||
|
const rightReady = rightPlayer?.kpm_status === "ready";
|
||||||
|
if (leftReady === rightReady) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return leftReady ? -1 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
function compareInactivePriority(a, b) {
|
function compareInactivePriority(a, b) {
|
||||||
return Number(a.inactive) - Number(b.inactive);
|
return Number(a.inactive) - Number(b.inactive);
|
||||||
}
|
}
|
||||||
@@ -426,13 +450,14 @@ function renderPlayerRows(player, item, index, inactive = false) {
|
|||||||
<td>${escapeHtml(formatOptionalNumber(player.deaths))}</td>
|
<td>${escapeHtml(formatOptionalNumber(player.deaths))}</td>
|
||||||
<td>${escapeHtml(formatOptionalNumber(player.teamkills))}</td>
|
<td>${escapeHtml(formatOptionalNumber(player.teamkills))}</td>
|
||||||
<td>${escapeHtml(formatKdRatio(player))}</td>
|
<td>${escapeHtml(formatKdRatio(player))}</td>
|
||||||
|
<td>${escapeHtml(formatReadyKpmValue(player))}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr
|
<tr
|
||||||
class="historical-player-detail-row"
|
class="historical-player-detail-row"
|
||||||
id="${escapeHtml(panelId)}"
|
id="${escapeHtml(panelId)}"
|
||||||
aria-labelledby="${escapeHtml(rowId)}"
|
aria-labelledby="${escapeHtml(rowId)}"
|
||||||
>
|
>
|
||||||
<td colspan="6">
|
<td colspan="7">
|
||||||
${renderPlayerStatsPanel(player, item, { team, playerName })}
|
${renderPlayerStatsPanel(player, item, { team, playerName })}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -887,6 +912,22 @@ function formatKdRatio(player) {
|
|||||||
return formatDecimal(getKdRatioValue(player), 2);
|
return formatDecimal(getKdRatioValue(player), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getReadyKpmValue(player) {
|
||||||
|
if (player?.kpm_status !== "ready") {
|
||||||
|
return Number.NaN;
|
||||||
|
}
|
||||||
|
const value = Number(player.kpm);
|
||||||
|
return Number.isFinite(value) ? value : Number.NaN;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatReadyKpmValue(player) {
|
||||||
|
const value = getReadyKpmValue(player);
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return formatDecimal(value, 2);
|
||||||
|
}
|
||||||
|
|
||||||
function getKdRatioValue(player) {
|
function getKdRatioValue(player) {
|
||||||
if (Number.isFinite(Number(player.kd_ratio))) {
|
if (Number.isFinite(Number(player.kd_ratio))) {
|
||||||
return Number(player.kd_ratio);
|
return Number(player.kd_ratio);
|
||||||
|
|||||||
@@ -616,7 +616,11 @@ function hydrateWeeklyLeaderboard(
|
|||||||
(item) => {
|
(item) => {
|
||||||
const kills = resolveHistoricalKills(item, metricConfig);
|
const kills = resolveHistoricalKills(item, metricConfig);
|
||||||
const matches = Number(item.matches_considered);
|
const matches = Number(item.matches_considered);
|
||||||
const kpm = formatHistoricalKpm(item?.kills_per_match, kills, matches);
|
const killsPerMatch = formatHistoricalKillsPerMatch(
|
||||||
|
item?.kills_per_match,
|
||||||
|
kills,
|
||||||
|
matches,
|
||||||
|
);
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<tr>
|
<tr>
|
||||||
@@ -624,7 +628,7 @@ function hydrateWeeklyLeaderboard(
|
|||||||
<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>
|
<td>${escapeHtml(killsPerMatch)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
`;
|
`;
|
||||||
},
|
},
|
||||||
@@ -1720,7 +1724,7 @@ function resolveHistoricalKills(item, metricConfig) {
|
|||||||
return Number.NaN;
|
return Number.NaN;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatHistoricalKpm(rawKillsPerMatch, rawKills, rawMatches) {
|
function formatHistoricalKillsPerMatch(rawKillsPerMatch, rawKills, rawMatches) {
|
||||||
const directValue = Number(rawKillsPerMatch);
|
const directValue = Number(rawKillsPerMatch);
|
||||||
if (Number.isFinite(directValue)) {
|
if (Number.isFinite(directValue)) {
|
||||||
return formatDecimal(directValue, 2);
|
return formatDecimal(directValue, 2);
|
||||||
|
|||||||
@@ -361,7 +361,11 @@
|
|||||||
const playerName = escapeHtml(String(item.player_name || "Jugador sin nombre"));
|
const playerName = escapeHtml(String(item.player_name || "Jugador sin nombre"));
|
||||||
const kills = safeInt(firstFiniteValue(item.kills, 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 killsPerMatch = formatKillsPerMatch(
|
||||||
|
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");
|
||||||
@@ -376,7 +380,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="stats-annual-metric">${kills}</td>
|
<td class="stats-annual-metric">${kills}</td>
|
||||||
<td>${matches}</td>
|
<td>${matches}</td>
|
||||||
<td>${kpm}</td>
|
<td>${killsPerMatch}</td>
|
||||||
<td>${deaths}</td>
|
<td>${deaths}</td>
|
||||||
<td>${teamkills}</td>
|
<td>${teamkills}</td>
|
||||||
<td>${kd}</td>
|
<td>${kd}</td>
|
||||||
@@ -394,7 +398,7 @@
|
|||||||
<th>Jugador</th>
|
<th>Jugador</th>
|
||||||
<th>Kills</th>
|
<th>Kills</th>
|
||||||
<th>Partidas</th>
|
<th>Partidas</th>
|
||||||
<th>KPM</th>
|
<th>Kills/partida</th>
|
||||||
<th>Muertes</th>
|
<th>Muertes</th>
|
||||||
<th>Teamkills</th>
|
<th>Teamkills</th>
|
||||||
<th>K/D</th>
|
<th>K/D</th>
|
||||||
@@ -710,8 +714,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderDeltaComparisonCard(weeklyData, monthlyData) {
|
function renderDeltaComparisonCard(weeklyData, monthlyData) {
|
||||||
const weeklyKpm = safeParseNumber(weeklyData?.kills_per_match);
|
const weeklyKillsPerMatch = safeParseNumber(weeklyData?.kills_per_match);
|
||||||
const monthlyKpm = safeParseNumber(monthlyData?.kills_per_match);
|
const monthlyKillsPerMatch = safeParseNumber(monthlyData?.kills_per_match);
|
||||||
const weeklyKd = safeParseNumber(weeklyData?.kd_ratio);
|
const weeklyKd = safeParseNumber(weeklyData?.kd_ratio);
|
||||||
const monthlyKd = safeParseNumber(monthlyData?.kd_ratio);
|
const monthlyKd = safeParseNumber(monthlyData?.kd_ratio);
|
||||||
const killsDelta = safeInt(monthlyData?.kills, 0) - safeInt(weeklyData?.kills, 0);
|
const killsDelta = safeInt(monthlyData?.kills, 0) - safeInt(weeklyData?.kills, 0);
|
||||||
@@ -727,12 +731,12 @@
|
|||||||
</span>
|
</span>
|
||||||
<div class="stats-comparison-card__grid">
|
<div class="stats-comparison-card__grid">
|
||||||
<div class="stats-comparison-card__metric">
|
<div class="stats-comparison-card__metric">
|
||||||
<span class="stats-comparison-card__metric-label">KPM semanal</span>
|
<span class="stats-comparison-card__metric-label">Kills/partida semanal</span>
|
||||||
<span class="stats-comparison-card__metric-value">${safeDecimal(weeklyKpm, 2, "0.00")}</span>
|
<span class="stats-comparison-card__metric-value">${safeDecimal(weeklyKillsPerMatch, 2, "0.00")}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stats-comparison-card__metric">
|
<div class="stats-comparison-card__metric">
|
||||||
<span class="stats-comparison-card__metric-label">KPM mensual</span>
|
<span class="stats-comparison-card__metric-label">Kills/partida mensual</span>
|
||||||
<span class="stats-comparison-card__metric-value">${safeDecimal(monthlyKpm, 2, "0.00")}</span>
|
<span class="stats-comparison-card__metric-value">${safeDecimal(monthlyKillsPerMatch, 2, "0.00")}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stats-comparison-card__metric">
|
<div class="stats-comparison-card__metric">
|
||||||
<span class="stats-comparison-card__metric-label">K/D semanal</span>
|
<span class="stats-comparison-card__metric-label">K/D semanal</span>
|
||||||
@@ -893,7 +897,7 @@
|
|||||||
return Number.NaN;
|
return Number.NaN;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatKpm(rawKillsPerMatch, rawKills, rawMatches) {
|
function formatKillsPerMatch(rawKillsPerMatch, rawKills, rawMatches) {
|
||||||
const directValue = safeParseNumber(rawKillsPerMatch);
|
const directValue = safeParseNumber(rawKillsPerMatch);
|
||||||
if (Number.isFinite(directValue)) {
|
if (Number.isFinite(directValue)) {
|
||||||
return safeDecimal(directValue, 2, "0.00");
|
return safeDecimal(directValue, 2, "0.00");
|
||||||
|
|||||||
@@ -138,6 +138,7 @@
|
|||||||
<option value="deaths">Muertes</option>
|
<option value="deaths">Muertes</option>
|
||||||
<option value="teamkills">TK</option>
|
<option value="teamkills">TK</option>
|
||||||
<option value="kd">KD</option>
|
<option value="kd">KD</option>
|
||||||
|
<option value="kpm">KPM</option>
|
||||||
<option value="name">Jugador</option>
|
<option value="name">Jugador</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
@@ -159,6 +160,7 @@
|
|||||||
<th>Muertes</th>
|
<th>Muertes</th>
|
||||||
<th>TK</th>
|
<th>TK</th>
|
||||||
<th>KD</th>
|
<th>KD</th>
|
||||||
|
<th>KPM</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="match-detail-players-body"></tbody>
|
<tbody id="match-detail-players-body"></tbody>
|
||||||
|
|||||||
@@ -199,7 +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>
|
<th>Kills/partida</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="weekly-leaderboard-body"></tbody>
|
<tbody id="weekly-leaderboard-body"></tbody>
|
||||||
|
|||||||
Reference in New Issue
Block a user