Fix historical ratio values and stats profile performance regression
This commit is contained in:
@@ -0,0 +1,89 @@
|
|||||||
|
# TASK-254 - Fix historical ratio values and stats profile performance regression
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
This task fixes two regressions detected before pushing TASK-252 and TASK-253:
|
||||||
|
|
||||||
|
1. Historical leaderboard ratio columns could show `0,00` when the direct ratio field was `null`.
|
||||||
|
2. The public player profile in `stats.html` could become slow when the weekly/monthly read model was empty and the endpoint fell back to runtime aggregation.
|
||||||
|
|
||||||
|
## Historical Ratios
|
||||||
|
|
||||||
|
Root cause:
|
||||||
|
|
||||||
|
- `formatHistoricalPerMatch(...)` converted `null` direct values with `Number(null)`, which becomes `0`.
|
||||||
|
- For `Top muertes` and `Soporte`, that caused the formatter to stop early and render `0,00` instead of dividing the real total by matches.
|
||||||
|
|
||||||
|
Fix:
|
||||||
|
|
||||||
|
- treat `null`, `undefined` and empty string as missing direct values
|
||||||
|
- only use a direct ratio value when it is truly present
|
||||||
|
- otherwise compute:
|
||||||
|
- kills / partidas
|
||||||
|
- muertes / partidas
|
||||||
|
- soporte / partidas
|
||||||
|
- if `matches <= 0` or the total is missing, render an empty string instead of a fake `0,00`
|
||||||
|
|
||||||
|
Expected examples:
|
||||||
|
|
||||||
|
- kills `170`, matches `4` -> `42,50`
|
||||||
|
- deaths `225`, matches `14` -> `16,07`
|
||||||
|
|
||||||
|
## Stats Profile Performance
|
||||||
|
|
||||||
|
Root cause:
|
||||||
|
|
||||||
|
- TASK-253 added a runtime fallback for weekly/monthly personal profile reads when `player_period_stats` was empty.
|
||||||
|
- That fallback queried live materialized match/player tables and ranking windows on the request path.
|
||||||
|
- For public profile reads this is too expensive and can degrade page responsiveness.
|
||||||
|
|
||||||
|
Fix:
|
||||||
|
|
||||||
|
- disable the runtime fallback for weekly/monthly profile reads
|
||||||
|
- keep the endpoint read-model-first only for public weekly/monthly profile windows
|
||||||
|
- when the read model is missing:
|
||||||
|
- return a lightweight profile payload
|
||||||
|
- keep external profile links and platform identity
|
||||||
|
- keep `kpm = null`
|
||||||
|
- keep `kpm_status = missing_active_time`
|
||||||
|
|
||||||
|
This preserves:
|
||||||
|
|
||||||
|
- translated labels
|
||||||
|
- hidden personal ID in the visible profile card
|
||||||
|
- external profile buttons
|
||||||
|
- no fake KPM
|
||||||
|
|
||||||
|
## Teamkills
|
||||||
|
|
||||||
|
No TeamKills change was implemented here.
|
||||||
|
|
||||||
|
Reference:
|
||||||
|
|
||||||
|
- `TASK-251-investigate-teamkills-ranking-zeroes.md`
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
Executed:
|
||||||
|
|
||||||
|
- `node --check frontend/assets/js/historico.js`
|
||||||
|
- `node --check frontend/assets/js/stats.js`
|
||||||
|
- `python -m compileall backend/app`
|
||||||
|
- `cd backend; python -m unittest tests.test_current_match_payload`
|
||||||
|
- `cd backend; python -m unittest tests.test_historical_snapshot_refresh`
|
||||||
|
- `cd backend; python -m unittest tests.test_stats_player_profile_payload`
|
||||||
|
|
||||||
|
Static/manual checks:
|
||||||
|
|
||||||
|
- no `undefined` titles remain in historical leaderboard labels
|
||||||
|
- `Kills/partida` only stays on `Top kills`
|
||||||
|
- `Muertes/partida` stays on `Top muertes`
|
||||||
|
- `matches_over_100_kills` keeps no ratio column
|
||||||
|
- Steam profile buttons still resolve to Steam + Hellor + HLL Records + Helo
|
||||||
|
- Epic profile buttons still resolve to Hellor + HLL Records
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- No backend scheduler change.
|
||||||
|
- No RCON/server configuration change.
|
||||||
|
- No asset change.
|
||||||
@@ -378,18 +378,12 @@ def get_rcon_materialized_player_stats(
|
|||||||
if read_model_result is not None:
|
if read_model_result is not None:
|
||||||
return read_model_result
|
return read_model_result
|
||||||
|
|
||||||
runtime_result = _get_rcon_materialized_player_stats_runtime(
|
return _build_missing_player_period_stats_result(
|
||||||
player_id=normalized_player_id,
|
player_id=normalized_player_id,
|
||||||
server_id=server_id,
|
server_id=server_id,
|
||||||
timeframe=resolved_timeframe,
|
timeframe=resolved_timeframe,
|
||||||
db_path=db_path,
|
missing_reason=fallback_reason or "player-period-stats-unavailable",
|
||||||
)
|
)
|
||||||
runtime_result.setdefault("source", {})
|
|
||||||
runtime_result["source"]["fallback_used"] = True
|
|
||||||
runtime_result["source"]["fallback_reason"] = fallback_reason or "player-period-stats-unavailable"
|
|
||||||
runtime_result["source"]["read_model"] = "player-period-stats"
|
|
||||||
runtime_result["source"]["freshness"] = "runtime-fallback"
|
|
||||||
return runtime_result
|
|
||||||
|
|
||||||
return _get_rcon_materialized_player_stats_runtime(
|
return _get_rcon_materialized_player_stats_runtime(
|
||||||
player_id=normalized_player_id,
|
player_id=normalized_player_id,
|
||||||
@@ -1655,6 +1649,21 @@ def _build_missing_player_period_stats_result(
|
|||||||
"kills": 0,
|
"kills": 0,
|
||||||
"deaths": 0,
|
"deaths": 0,
|
||||||
"teamkills": 0,
|
"teamkills": 0,
|
||||||
|
"player_active_seconds": None,
|
||||||
|
"player_active_minutes": None,
|
||||||
|
"kpm": None,
|
||||||
|
"kpm_status": "missing_active_time",
|
||||||
|
"active_time_source": "unavailable",
|
||||||
|
"active_time_coverage": {
|
||||||
|
"eligible_matches": 0,
|
||||||
|
"real_source_matches": 0,
|
||||||
|
"observed_matches": 0,
|
||||||
|
"total_matches_considered": 0,
|
||||||
|
"eligible_kills": 0,
|
||||||
|
"minimum_active_seconds": get_kpm_min_active_seconds(),
|
||||||
|
"sources": [],
|
||||||
|
},
|
||||||
|
**build_external_player_profile_fields(player_id=player_id),
|
||||||
"weekly_ranking": None,
|
"weekly_ranking": None,
|
||||||
"monthly_ranking": None,
|
"monthly_ranking": None,
|
||||||
"source": {
|
"source": {
|
||||||
|
|||||||
@@ -11,40 +11,22 @@ from app.rcon_historical_player_stats import (
|
|||||||
|
|
||||||
|
|
||||||
class StatsPlayerProfilePayloadTests(unittest.TestCase):
|
class StatsPlayerProfilePayloadTests(unittest.TestCase):
|
||||||
def test_player_stats_falls_back_to_runtime_when_period_read_model_is_empty(self) -> None:
|
def test_player_stats_returns_lightweight_missing_payload_when_period_read_model_is_empty(self) -> None:
|
||||||
runtime_payload = {
|
with patch(
|
||||||
"player_id": "76561198000000000",
|
"app.rcon_historical_player_stats._get_player_period_stats_read_model",
|
||||||
"player_name": "Runtime Player",
|
return_value=(None, "player-period-stats-empty"),
|
||||||
"server_id": "all-servers",
|
|
||||||
"timeframe": "weekly",
|
|
||||||
"window_kind": "current-week",
|
|
||||||
"matches_considered": 3,
|
|
||||||
"kills": 25,
|
|
||||||
"deaths": 12,
|
|
||||||
"teamkills": 1,
|
|
||||||
"weekly_ranking": {"metric": "kills", "ranking_position": 5},
|
|
||||||
"monthly_ranking": {"metric": "kills", "ranking_position": 8},
|
|
||||||
"source": {"primary_source": "rcon"},
|
|
||||||
}
|
|
||||||
with (
|
|
||||||
patch(
|
|
||||||
"app.rcon_historical_player_stats._get_player_period_stats_read_model",
|
|
||||||
return_value=(None, "player-period-stats-empty"),
|
|
||||||
),
|
|
||||||
patch(
|
|
||||||
"app.rcon_historical_player_stats._get_rcon_materialized_player_stats_runtime",
|
|
||||||
return_value=runtime_payload,
|
|
||||||
) as runtime_loader,
|
|
||||||
):
|
):
|
||||||
result = get_rcon_materialized_player_stats(
|
result = get_rcon_materialized_player_stats(
|
||||||
player_id="76561198000000000",
|
player_id="76561198000000000",
|
||||||
timeframe="weekly",
|
timeframe="weekly",
|
||||||
)
|
)
|
||||||
|
|
||||||
runtime_loader.assert_called_once()
|
self.assertEqual(result["matches_considered"], 0)
|
||||||
self.assertTrue(result["source"]["fallback_used"])
|
self.assertEqual(result["kpm_status"], "missing_active_time")
|
||||||
self.assertEqual(result["source"]["fallback_reason"], "player-period-stats-empty")
|
self.assertEqual(result["platform"], "steam")
|
||||||
self.assertEqual(result["source"]["freshness"], "runtime-fallback")
|
self.assertIn("steam", result["external_profile_links"])
|
||||||
|
self.assertEqual(result["source"]["missing_reason"], "player-period-stats-empty")
|
||||||
|
self.assertFalse(result["source"]["fallback_used"])
|
||||||
|
|
||||||
def test_build_stats_player_profile_payload_exposes_ready_real_kpm_and_external_links(self) -> None:
|
def test_build_stats_player_profile_payload_exposes_ready_real_kpm_and_external_links(self) -> None:
|
||||||
with patch(
|
with patch(
|
||||||
|
|||||||
@@ -176,7 +176,11 @@ This avoids false `0.00` values for:
|
|||||||
|
|
||||||
## Personal Player Profile
|
## Personal Player Profile
|
||||||
|
|
||||||
The public player profile in `stats.html` can now expose real KPM for the selected weekly or monthly window when the profile endpoint can aggregate:
|
The public player profile in `stats.html` can now expose real KPM for the selected weekly or monthly window when the lightweight player-period read model already contains enough data for that player and scope.
|
||||||
|
|
||||||
|
The profile endpoint does not use a runtime fallback over `rcon_match_player_stats` anymore for weekly/monthly public reads. That fallback was disabled because it could degrade public profile response times when the read model was empty or stale.
|
||||||
|
|
||||||
|
When the read model is available, profile KPM uses:
|
||||||
|
|
||||||
- `SUM(player_active_seconds)`
|
- `SUM(player_active_seconds)`
|
||||||
- only over rows with:
|
- only over rows with:
|
||||||
@@ -195,7 +199,7 @@ This is intentionally narrower than the window total:
|
|||||||
- profile `kills` still show all kills in the selected window
|
- profile `kills` still show all kills in the selected window
|
||||||
- profile `KPM` only becomes `ready` when the active-time subset is reliable enough
|
- profile `KPM` only becomes `ready` when the active-time subset is reliable enough
|
||||||
|
|
||||||
The profile payload now exposes:
|
The profile payload exposes:
|
||||||
|
|
||||||
- `player_active_seconds`
|
- `player_active_seconds`
|
||||||
- `player_active_minutes`
|
- `player_active_minutes`
|
||||||
@@ -204,7 +208,14 @@ The profile payload now exposes:
|
|||||||
- `active_time_source`
|
- `active_time_source`
|
||||||
- `active_time_coverage`
|
- `active_time_coverage`
|
||||||
|
|
||||||
If the profile read model is empty, the endpoint can safely fall back to runtime aggregation over `rcon_match_player_stats` for that single player and time window. This avoids inventing KPM while keeping the page usable before the read model is refreshed.
|
If the profile read model is empty or unavailable:
|
||||||
|
|
||||||
|
- the endpoint keeps the profile fast
|
||||||
|
- `kpm` stays `null`
|
||||||
|
- `kpm_status` stays `missing_active_time`
|
||||||
|
- external links and profile identity fields remain available
|
||||||
|
|
||||||
|
This keeps the page responsive without inventing KPM.
|
||||||
|
|
||||||
## Kills Per Match Labels
|
## Kills Per Match Labels
|
||||||
|
|
||||||
|
|||||||
@@ -1783,18 +1783,24 @@ function formatHistoricalRatio(item, metricConfig, matches) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatHistoricalPerMatch(rawDirectValue, rawTotalValue, rawMatches) {
|
function formatHistoricalPerMatch(rawDirectValue, rawTotalValue, rawMatches) {
|
||||||
const directValue = Number(rawDirectValue);
|
const directValue =
|
||||||
|
rawDirectValue === null || rawDirectValue === undefined || rawDirectValue === ""
|
||||||
|
? Number.NaN
|
||||||
|
: Number(rawDirectValue);
|
||||||
if (Number.isFinite(directValue)) {
|
if (Number.isFinite(directValue)) {
|
||||||
return formatDecimal(directValue, 2);
|
return formatDecimal(directValue, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
const kills = Number(rawTotalValue);
|
const totalValue =
|
||||||
|
rawTotalValue === null || rawTotalValue === undefined || rawTotalValue === ""
|
||||||
|
? Number.NaN
|
||||||
|
: Number(rawTotalValue);
|
||||||
const matches = Number(rawMatches);
|
const matches = Number(rawMatches);
|
||||||
if (!Number.isFinite(kills) || !Number.isFinite(matches) || matches <= 0) {
|
if (!Number.isFinite(totalValue) || !Number.isFinite(matches) || matches <= 0) {
|
||||||
return "-";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
return formatDecimal(kills / matches, 2);
|
return formatDecimal(totalValue / matches, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDateOnly(timestamp) {
|
function formatDateOnly(timestamp) {
|
||||||
|
|||||||
Reference in New Issue
Block a user