diff --git a/ai/tasks/done/TASK-253-polish-player-profile-kpm-links-and-labels.md b/ai/tasks/done/TASK-253-polish-player-profile-kpm-links-and-labels.md new file mode 100644 index 0000000..cb3f778 --- /dev/null +++ b/ai/tasks/done/TASK-253-polish-player-profile-kpm-links-and-labels.md @@ -0,0 +1,128 @@ +# TASK-253 - Polish player profile KPM, links and labels + +## Summary + +This task improves the public player profile in `stats.html` without changing the validated historical match-detail KPM model. + +Changes included: + +- real weekly/monthly profile KPM when safe +- no visible raw player ID in the personal profile card or profile heading +- translated `current-week` / `current-month` labels +- external profile buttons aligned with `historico-partida` + +Teamkills were not changed here. TASK-251 remains the reference audit for that problem. + +## Files Read First + +- `frontend/stats.html` +- `frontend/assets/js/stats.js` +- `frontend/assets/js/historico-partida.js` +- `backend/app/payloads.py` +- `backend/app/rcon_historical_player_stats.py` +- `docs/HISTORICAL_MATCH_KILLS_PER_MINUTE_ANALYSIS.md` + +## Real KPM Decision + +The player profile does not use match duration and does not reuse `kills_per_match`. + +It now exposes real KPM only when the selected weekly or monthly window can aggregate player facts with: + +- `active_time_source = connection_intervals` +- `active_time_source = connection_intervals_carryover` +- `player_active_seconds >= HLL_KPM_MIN_ACTIVE_SECONDS` + +Formula: + +```text +sum(eligible_kills) / (sum(eligible_player_active_seconds) / 60) +``` + +If the profile window has: + +- no active time -> `kpm_status = missing_active_time` +- only fallback spans without reliable connection intervals -> `kpm_status = missing_connection_intervals` +- real connection rows but below threshold -> `kpm_status = insufficient_active_time` + +Only `kpm_status = ready` is rendered in the frontend. + +## Backend Notes + +`build_stats_player_profile_payload(...)` now exposes: + +- `platform` +- `steam_id_64` +- `epic_id` +- `external_profile_links` +- `player_active_seconds` +- `player_active_minutes` +- `kpm` +- `kpm_status` +- `active_time_source` +- `active_time_coverage` + +If `player_period_stats` is unavailable for the requested weekly/monthly profile, the endpoint now falls back to runtime aggregation for that single player and window instead of returning an empty read-model-only payload. + +This keeps the route useful without touching scheduler or snapshot policies. + +## Frontend Notes + +Profile polish in `stats.js`: + +- removed visible `ID:` from the identity card +- removed raw player ID from the visible profile heading/state +- translated: + - `current-week` -> `Semana actual` + - `current-month` -> `Mes actual` + - fallback labels for previous windows +- added external profile buttons using the same destinations as `historico-partida` +- keeps defensive image `onerror` +- renders KPM only when `kpm_status == ready` + +Profile buttons by platform: + +- Steam: + - Steam + - Hellor + - HLL Records + - Helo +- Epic: + - Hellor + - HLL Records + +## Teamkills Note + +No teamkills fix was implemented here. + +Reference: + +- `TASK-251-investigate-teamkills-ranking-zeroes.md` + +Observed state remains: + +- `rcon_match_player_stats.teamkills` stays at zero in the audited dataset +- same-team kill events were not present in the audited AdminLog dataset +- lifetime profile snapshots can show TK counters, but they are not a valid direct source for annual ranking without redesign/backfill + +## Validation + +Executed: + +- `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 checks: + +- no visible `ID:` remains in the personal profile card +- `current-week` / `current-month` no longer remain as visible UI labels +- external profile buttons do not print raw IDs on screen +- KPM is rendered only for `kpm_status == ready` + +## Notes + +- No scheduler changes. +- No RCON or server configuration changes. +- No asset changes were required. diff --git a/backend/app/payloads.py b/backend/app/payloads.py index 5cbc338..03719df 100644 --- a/backend/app/payloads.py +++ b/backend/app/payloads.py @@ -785,6 +785,10 @@ def build_stats_player_profile_payload( "data": { "player_id": result.get("player_id"), "player_name": result.get("player_name"), + "platform": result.get("platform"), + "steam_id_64": result.get("steam_id_64"), + "epic_id": result.get("epic_id"), + "external_profile_links": result.get("external_profile_links") or {}, "server_id": result.get("server_id"), "timeframe": result.get("timeframe"), "window_start": _to_iso_or_none(result.get("window_start")), @@ -797,6 +801,12 @@ def build_stats_player_profile_payload( "kd_ratio": kd_ratio, "kills_per_match": kills_per_match, "deaths_per_match": deaths_per_match, + "player_active_seconds": result.get("player_active_seconds"), + "player_active_minutes": result.get("player_active_minutes"), + "kpm": result.get("kpm"), + "kpm_status": result.get("kpm_status"), + "active_time_source": result.get("active_time_source"), + "active_time_coverage": result.get("active_time_coverage"), "weekly_ranking": result.get("weekly_ranking"), "monthly_ranking": result.get("monthly_ranking"), "source": result.get("source"), diff --git a/backend/app/rcon_historical_player_stats.py b/backend/app/rcon_historical_player_stats.py index bcb6c21..973562b 100644 --- a/backend/app/rcon_historical_player_stats.py +++ b/backend/app/rcon_historical_player_stats.py @@ -9,13 +9,20 @@ from datetime import date, datetime, timezone from pathlib import Path import unicodedata +from .config import get_kpm_min_active_seconds from .config import get_storage_path, use_postgres_rcon_storage from .historical_storage import ALL_SERVERS_SLUG +from .player_external_profiles import build_external_player_profile_fields from .rcon_admin_log_materialization import MATCH_RESULT_SOURCE, initialize_rcon_materialized_storage from .rcon_historical_leaderboards import select_leaderboard_window from .sqlite_utils import connect_sqlite_readonly, connect_sqlite_writer from .rcon_historical_leaderboards import _to_iso +REAL_KPM_ACTIVE_TIME_SOURCES = ( + "connection_intervals", + "connection_intervals_carryover", +) + PLAYER_SEARCH_INDEX_SERVER_KEYS = ( ALL_SERVERS_SLUG, "comunidad-hispana-01", @@ -371,12 +378,18 @@ def get_rcon_materialized_player_stats( if read_model_result is not None: return read_model_result - return _build_missing_player_period_stats_result( + runtime_result = _get_rcon_materialized_player_stats_runtime( player_id=normalized_player_id, server_id=server_id, timeframe=resolved_timeframe, - missing_reason=fallback_reason or "player-period-stats-unavailable", + db_path=db_path, ) + 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( player_id=normalized_player_id, @@ -415,6 +428,13 @@ def _get_rcon_materialized_player_stats_runtime( server_id=resolved_server_id, window=selected_window if resolved_timeframe != "all" else None, ) + active_time = _fetch_player_active_time_summary( + connection=connection, + player_id=normalized_player_id, + server_id=resolved_server_id, + window=selected_window if resolved_timeframe != "all" else None, + total_matches_considered=int(player_stats.get("matches_considered", 0) or 0), + ) source_range = _fetch_source_range( connection=connection, @@ -460,6 +480,8 @@ def _get_rcon_materialized_player_stats_runtime( "kills": player_stats.get("kills", 0), "deaths": player_stats.get("deaths", 0), "teamkills": player_stats.get("teamkills", 0), + **active_time, + **build_external_player_profile_fields(player_id=normalized_player_id), "weekly_ranking": weekly_ranking, "monthly_ranking": monthly_ranking, "source": { @@ -528,6 +550,17 @@ def _get_player_period_stats_read_model( selected_row = rows_by_period[timeframe] weekly_row = rows_by_period["weekly"] monthly_row = rows_by_period["monthly"] + with _connect_read_scope(resolved_path, db_path=db_path) as connection: + active_time = _fetch_player_active_time_summary( + connection=connection, + player_id=player_id, + server_id=resolved_server_id, + window={ + "start": _to_datetime_or_none(selected_row.get("period_start")), + "end": _to_datetime_or_none(selected_row.get("period_end")), + }, + total_matches_considered=int(selected_row.get("matches_considered") or 0), + ) return ( { "player_id": player_id, @@ -541,6 +574,8 @@ def _get_player_period_stats_read_model( "kills": int(selected_row.get("kills") or 0), "deaths": int(selected_row.get("deaths") or 0), "teamkills": int(selected_row.get("teamkills") or 0), + **active_time, + **build_external_player_profile_fields(player_id=player_id), "weekly_ranking": _build_player_period_ranking_payload(weekly_row), "monthly_ranking": _build_player_period_ranking_payload(monthly_row), "source": { @@ -825,6 +860,196 @@ def _fetch_player_stats( } +def _fetch_player_active_time_summary( + *, + connection: object, + player_id: str, + server_id: str | None, + window: dict[str, object] | None, + total_matches_considered: int, +) -> dict[str, object]: + scope_sql, scope_params = _build_scope_sql(server_id) + min_active_seconds = get_kpm_min_active_seconds() + base_where = [ + "matches.source_basis = ?", + "stats.player_id = ?", + ] + params: list[object] = [MATCH_RESULT_SOURCE, player_id] + if window is not None: + base_where.append( + "COALESCE(CAST(matches.ended_at AS TEXT), CAST(matches.started_at AS TEXT)) >= ?" + ) + base_where.append( + "COALESCE(CAST(matches.ended_at AS TEXT), CAST(matches.started_at AS TEXT)) <= ?" + ) + params.extend([_to_iso(window["start"]), _to_iso(window["end"])]) + where_sql = " AND ".join(base_where) + eligible_sources_placeholders = ", ".join(["?"] * len(REAL_KPM_ACTIVE_TIME_SOURCES)) + row = connection.execute( + f""" + SELECT + COUNT(DISTINCT CASE + WHEN stats.player_active_seconds IS NOT NULL + THEN stats.match_key + ELSE NULL + END) AS observed_matches, + COUNT(DISTINCT CASE + WHEN stats.active_time_source IN ({eligible_sources_placeholders}) + THEN stats.match_key + ELSE NULL + END) AS real_source_matches, + COUNT(DISTINCT CASE + WHEN stats.active_time_source IN ({eligible_sources_placeholders}) + AND COALESCE(stats.player_active_seconds, 0) >= ? + THEN stats.match_key + ELSE NULL + END) AS eligible_matches, + SUM(CASE + WHEN stats.active_time_source IN ({eligible_sources_placeholders}) + AND COALESCE(stats.player_active_seconds, 0) >= ? + THEN COALESCE(stats.player_active_seconds, 0) + ELSE 0 + END) AS player_active_seconds, + SUM(CASE + WHEN stats.active_time_source IN ({eligible_sources_placeholders}) + AND COALESCE(stats.player_active_seconds, 0) >= ? + THEN COALESCE(stats.kills, 0) + ELSE 0 + END) AS eligible_kills, + GROUP_CONCAT(DISTINCT CASE + WHEN stats.active_time_source IN ({eligible_sources_placeholders}) + THEN stats.active_time_source + ELSE NULL + END) AS eligible_sources, + GROUP_CONCAT(DISTINCT COALESCE(stats.active_time_source, 'unavailable')) AS observed_sources + FROM rcon_match_player_stats AS stats + INNER JOIN rcon_materialized_matches AS matches + ON matches.target_key = stats.target_key + AND matches.match_key = stats.match_key + WHERE {where_sql} {scope_sql} + """, + [ + *REAL_KPM_ACTIVE_TIME_SOURCES, + *REAL_KPM_ACTIVE_TIME_SOURCES, + min_active_seconds, + *REAL_KPM_ACTIVE_TIME_SOURCES, + min_active_seconds, + *REAL_KPM_ACTIVE_TIME_SOURCES, + min_active_seconds, + *REAL_KPM_ACTIVE_TIME_SOURCES, + *params, + *scope_params, + ], + ).fetchone() + return _build_profile_active_time_payload( + row=dict(row) if row is not None else {}, + total_matches_considered=total_matches_considered, + min_active_seconds=min_active_seconds, + ) + + +def _build_profile_active_time_payload( + *, + row: dict[str, object], + total_matches_considered: int, + min_active_seconds: int, +) -> dict[str, object]: + observed_matches = int(row.get("observed_matches") or 0) + real_source_matches = int(row.get("real_source_matches") or 0) + eligible_matches = int(row.get("eligible_matches") or 0) + player_active_seconds = int(row.get("player_active_seconds") or 0) + eligible_kills = int(row.get("eligible_kills") or 0) + eligible_sources = _split_sources(row.get("eligible_sources")) + observed_sources = _split_sources(row.get("observed_sources")) + + if eligible_matches > 0 and player_active_seconds >= min_active_seconds: + player_active_minutes = round(player_active_seconds / 60, 3) + return { + "player_active_seconds": player_active_seconds, + "player_active_minutes": player_active_minutes, + "kpm": round(eligible_kills / (player_active_seconds / 60), 2), + "kpm_status": "ready", + "active_time_source": _summarize_real_active_time_sources(eligible_sources), + "active_time_coverage": { + "eligible_matches": eligible_matches, + "real_source_matches": real_source_matches, + "observed_matches": observed_matches, + "total_matches_considered": total_matches_considered, + "eligible_kills": eligible_kills, + "minimum_active_seconds": min_active_seconds, + "sources": eligible_sources, + }, + } + + if real_source_matches > 0: + return { + "player_active_seconds": None, + "player_active_minutes": None, + "kpm": None, + "kpm_status": "insufficient_active_time", + "active_time_source": _summarize_real_active_time_sources(eligible_sources or observed_sources), + "active_time_coverage": { + "eligible_matches": eligible_matches, + "real_source_matches": real_source_matches, + "observed_matches": observed_matches, + "total_matches_considered": total_matches_considered, + "eligible_kills": eligible_kills, + "minimum_active_seconds": min_active_seconds, + "sources": observed_sources, + }, + } + + if observed_matches > 0 or "event_span_fallback" in observed_sources: + return { + "player_active_seconds": None, + "player_active_minutes": None, + "kpm": None, + "kpm_status": "missing_connection_intervals", + "active_time_source": "event_span_fallback" if observed_sources else "unavailable", + "active_time_coverage": { + "eligible_matches": 0, + "real_source_matches": real_source_matches, + "observed_matches": observed_matches, + "total_matches_considered": total_matches_considered, + "eligible_kills": 0, + "minimum_active_seconds": min_active_seconds, + "sources": observed_sources, + }, + } + + return { + "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": total_matches_considered, + "eligible_kills": 0, + "minimum_active_seconds": min_active_seconds, + "sources": [], + }, + } + + +def _split_sources(value: object) -> list[str]: + if not isinstance(value, str) or not value.strip(): + return [] + return [item for item in {part.strip() for part in value.split(",")} if item] + + +def _summarize_real_active_time_sources(sources: list[str]) -> str: + normalized = [source for source in sources if source in REAL_KPM_ACTIVE_TIME_SOURCES] + if not normalized: + return "unavailable" + if len(normalized) == 1: + return normalized[0] + return "connection_intervals_mixed" + + def _fetch_player_ranking( *, connection: object, diff --git a/backend/tests/test_stats_player_profile_payload.py b/backend/tests/test_stats_player_profile_payload.py new file mode 100644 index 0000000..af64882 --- /dev/null +++ b/backend/tests/test_stats_player_profile_payload.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +import unittest +from unittest.mock import patch + +from app.payloads import build_stats_player_profile_payload +from app.rcon_historical_player_stats import ( + _build_profile_active_time_payload, + get_rcon_materialized_player_stats, +) + + +class StatsPlayerProfilePayloadTests(unittest.TestCase): + def test_player_stats_falls_back_to_runtime_when_period_read_model_is_empty(self) -> None: + runtime_payload = { + "player_id": "76561198000000000", + "player_name": "Runtime Player", + "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( + player_id="76561198000000000", + timeframe="weekly", + ) + + runtime_loader.assert_called_once() + self.assertTrue(result["source"]["fallback_used"]) + self.assertEqual(result["source"]["fallback_reason"], "player-period-stats-empty") + self.assertEqual(result["source"]["freshness"], "runtime-fallback") + + def test_build_stats_player_profile_payload_exposes_ready_real_kpm_and_external_links(self) -> None: + with patch( + "app.payloads.get_rcon_materialized_player_stats", + return_value={ + "player_id": "76561198000000000", + "player_name": "Steam Player", + "server_id": "all-servers", + "timeframe": "weekly", + "window_start": None, + "window_end": None, + "window_kind": "current-week", + "matches_considered": 4, + "kills": 20, + "deaths": 10, + "teamkills": 0, + "player_active_seconds": 1200, + "player_active_minutes": 20.0, + "kpm": 1.0, + "kpm_status": "ready", + "active_time_source": "connection_intervals", + "active_time_coverage": { + "eligible_matches": 4, + "real_source_matches": 4, + "observed_matches": 4, + "total_matches_considered": 4, + "eligible_kills": 20, + "minimum_active_seconds": 60, + "sources": ["connection_intervals"], + }, + "platform": "steam", + "steam_id_64": "76561198000000000", + "external_profile_links": { + "steam": "https://steamcommunity.com/profiles/76561198000000000", + "hellor": "https://hellor.pro/player/76561198000000000", + "hll_records": "https://hllrecords.com/profiles/76561198000000000", + "helo": "https://helo-system.de/statistics/players/76561198000000000?series=2024", + }, + "weekly_ranking": {"metric": "kills", "ranking_position": 4}, + "monthly_ranking": {"metric": "kills", "ranking_position": 7}, + "source": {"primary_source": "rcon"}, + }, + ): + payload = build_stats_player_profile_payload( + player_id="76561198000000000", + timeframe="weekly", + ) + + data = payload["data"] + self.assertEqual(data["kpm"], 1.0) + self.assertEqual(data["kpm_status"], "ready") + self.assertEqual(data["active_time_source"], "connection_intervals") + self.assertEqual(data["steam_id_64"], "76561198000000000") + self.assertEqual(data["platform"], "steam") + self.assertIn("steam", data["external_profile_links"]) + + def test_build_stats_player_profile_payload_keeps_kpm_null_when_connection_intervals_are_missing(self) -> None: + with patch( + "app.payloads.get_rcon_materialized_player_stats", + return_value={ + "player_id": "epic-player-id", + "player_name": "Epic Player", + "server_id": "all-servers", + "timeframe": "monthly", + "window_start": None, + "window_end": None, + "window_kind": "current-month", + "matches_considered": 2, + "kills": 7, + "deaths": 4, + "teamkills": 0, + "player_active_seconds": None, + "player_active_minutes": None, + "kpm": None, + "kpm_status": "missing_connection_intervals", + "active_time_source": "event_span_fallback", + "active_time_coverage": { + "eligible_matches": 0, + "real_source_matches": 0, + "observed_matches": 2, + "total_matches_considered": 2, + "eligible_kills": 0, + "minimum_active_seconds": 60, + "sources": ["event_span_fallback"], + }, + "platform": "epic", + "epic_id": "0123456789abcdef0123456789abcdef", + "external_profile_links": { + "hellor": "https://hellor.pro/player/0123456789abcdef0123456789abcdef", + "hll_records": "https://hllrecords.com/profiles/0123456789abcdef0123456789abcdef", + }, + "weekly_ranking": {"metric": "kills", "ranking_position": None}, + "monthly_ranking": {"metric": "kills", "ranking_position": None}, + "source": {"primary_source": "rcon"}, + }, + ): + payload = build_stats_player_profile_payload( + player_id="0123456789abcdef0123456789abcdef", + timeframe="monthly", + ) + + data = payload["data"] + self.assertIsNone(data["kpm"]) + self.assertEqual(data["kpm_status"], "missing_connection_intervals") + self.assertNotIn("steam", data["external_profile_links"]) + + def test_profile_active_time_payload_marks_ready_only_for_real_connection_intervals(self) -> None: + payload = _build_profile_active_time_payload( + row={ + "observed_matches": 3, + "real_source_matches": 3, + "eligible_matches": 3, + "player_active_seconds": 600, + "eligible_kills": 10, + "eligible_sources": "connection_intervals,connection_intervals_carryover", + "observed_sources": "connection_intervals,connection_intervals_carryover", + }, + total_matches_considered=3, + min_active_seconds=60, + ) + + self.assertEqual(payload["kpm"], 1.0) + self.assertEqual(payload["kpm_status"], "ready") + self.assertEqual(payload["active_time_source"], "connection_intervals_mixed") + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/HISTORICAL_MATCH_KILLS_PER_MINUTE_ANALYSIS.md b/docs/HISTORICAL_MATCH_KILLS_PER_MINUTE_ANALYSIS.md index 20b26ef..380124a 100644 --- a/docs/HISTORICAL_MATCH_KILLS_PER_MINUTE_ANALYSIS.md +++ b/docs/HISTORICAL_MATCH_KILLS_PER_MINUTE_ANALYSIS.md @@ -174,6 +174,38 @@ This avoids false `0.00` values for: - rows below `HLL_KPM_MIN_ACTIVE_SECONDS` - rows that only expose `event_span_fallback` +## 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: + +- `SUM(player_active_seconds)` +- only over rows with: + - `active_time_source = connection_intervals` + - `active_time_source = connection_intervals_carryover` + - `player_active_seconds >= HLL_KPM_MIN_ACTIVE_SECONDS` + +Weekly and monthly profile KPM use: + +```text +sum(eligible_kills) / (sum(eligible_player_active_seconds) / 60) +``` + +This is intentionally narrower than the window total: + +- profile `kills` still show all kills in the selected window +- profile `KPM` only becomes `ready` when the active-time subset is reliable enough + +The profile payload now exposes: + +- `player_active_seconds` +- `player_active_minutes` +- `kpm` +- `kpm_status` +- `active_time_source` +- `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. + ## Kills Per Match Labels Several public tables were showing `kills_per_match` under the visible label `KPM`. diff --git a/frontend/assets/css/styles.css b/frontend/assets/css/styles.css index 236c734..3b0675f 100644 --- a/frontend/assets/css/styles.css +++ b/frontend/assets/css/styles.css @@ -1448,6 +1448,39 @@ h2 { linear-gradient(180deg, rgba(28, 34, 25, 0.88), rgba(12, 15, 11, 0.96)); } +.stats-profile-links { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 10px; +} + +.stats-profile-links a { + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 42px; + padding: 0.55rem 0.9rem; + border: 1px solid rgba(159, 168, 141, 0.18); + border-radius: 999px; + background: rgba(15, 18, 13, 0.84); + color: var(--text); + text-decoration: none; + font-weight: 700; +} + +.stats-profile-links a:hover { + border-color: rgba(210, 182, 118, 0.34); + color: var(--accent-warm); +} + +.stats-profile-links__brand { + width: 18px; + height: 18px; + object-fit: contain; + flex: 0 0 auto; +} + .stats-summary-card--placeholder { border-color: rgba(210, 182, 118, 0.26); color: var(--accent-warm); diff --git a/frontend/assets/js/stats.js b/frontend/assets/js/stats.js index 44b51a6..cdf2adb 100644 --- a/frontend/assets/js/stats.js +++ b/frontend/assets/js/stats.js @@ -22,6 +22,24 @@ const annualMetricSupportedOnly = "kills"; const annualLimit = 20; const annualServerId = "all"; + const externalProfileBrands = Object.freeze({ + steam: Object.freeze({ + label: "Steam", + logoSrc: "./assets/img/brands/steam.png", + }), + 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", + }), + }); const messages = { backendUnavailableForSearch: @@ -478,26 +496,30 @@ const playerName = String( (weeklyData?.player_name || monthlyData?.player_name || "Sin nombre"), ); - const playerIdText = String(weeklyData?.player_id || monthlyData?.player_id || playerId); + const profileIdentityData = + weeklyData?.player_id || weeklyData?.external_profile_links + ? weeklyData + : monthlyData; const hasWeeklyStats = Number(weeklyData?.matches_considered || 0) > 0; const hasMonthlyStats = Number(monthlyData?.matches_considered || 0) > 0; const hasStats = hasWeeklyStats || hasMonthlyStats; const partialLoadWarning = weeklyFailed || monthlyFailed; profileStateNode.textContent = hasStats - ? `${playerName} (${playerIdText})` - : `${messages.profileNoStats} (${playerIdText})`; + ? playerName + : messages.profileNoStats; profileStateNode.className = hasStats ? "stats-state stats-state--neutral" : "stats-state stats-state--warning"; summaryGrid.innerHTML = renderProfileSummaryCards({ playerName, - playerIdText, + profileIdentityData, weeklyData, monthlyData, weeklyFailed, monthlyFailed, + externalProfileBrands, }); if (partialLoadWarning) { @@ -557,11 +579,12 @@ function renderProfileSummaryCards({ playerName, - playerIdText, + profileIdentityData, weeklyData, monthlyData, weeklyFailed, monthlyFailed, + externalProfileBrands, }) { const weeklySummary = weeklyFailed ? ` @@ -599,10 +622,10 @@

Identidad

Jugador: ${escapeHtml(playerName)}

-

ID: ${escapeHtml(playerIdText)}

Partidas semanales: ${safeInt(weeklyData?.matches_considered, 0)}

Partidas mensuales: ${safeInt(monthlyData?.matches_considered, 0)}

+ ${renderStatsExternalProfilesCard(profileIdentityData, externalProfileBrands)} ${weeklySummary} ${monthlySummary} `; @@ -679,6 +702,7 @@ 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"); + const kpmMetric = renderStatsKpmMetric(data); return `
@@ -704,6 +728,7 @@ KD ${kdRatio} + ${kpmMetric}

Kills/partida: ${killsPerMatch} @@ -718,6 +743,13 @@ const monthlyKillsPerMatch = safeParseNumber(monthlyData?.kills_per_match); const weeklyKd = safeParseNumber(weeklyData?.kd_ratio); const monthlyKd = safeParseNumber(monthlyData?.kd_ratio); + const weeklyKpm = safeParseNumber(weeklyData?.kpm); + const monthlyKpm = safeParseNumber(monthlyData?.kpm); + const hasReadyKpm = + weeklyData?.kpm_status === "ready" && + monthlyData?.kpm_status === "ready" && + Number.isFinite(weeklyKpm) && + Number.isFinite(monthlyKpm); const killsDelta = safeInt(monthlyData?.kills, 0) - safeInt(weeklyData?.kills, 0); const matchesDelta = safeInt(monthlyData?.matches_considered, 0) - safeInt(weeklyData?.matches_considered, 0); @@ -746,6 +778,20 @@ KD mensual ${safeDecimal(monthlyKd, 2, "0.00")} + ${ + hasReadyKpm + ? ` +

+ KPM semanal + ${safeDecimal(weeklyKpm, 2, "0.00")} +
+
+ KPM mensual + ${safeDecimal(monthlyKpm, 2, "0.00")} +
+ ` + : "" + }

Delta de kills: ${formatSignedNumber(killsDelta)} @@ -854,7 +900,7 @@ } function formatWindowRange(ranking) { - const windowLabel = String(ranking?.window_kind || "").trim(); + const windowLabel = labelForStatsWindowKind(ranking?.window_kind); const windowStart = formatDateTime(ranking?.window_start); const windowEnd = formatDateTime(ranking?.window_end); if (windowLabel) { @@ -875,6 +921,113 @@ return labels[String(metric || "").trim()] || "Kills"; } + function labelForStatsWindowKind(windowKind) { + const labels = { + "current-week": "Semana actual", + "current-month": "Mes actual", + "previous-closed-week-fallback": "Semana cerrada anterior", + "previous-closed-month-fallback": "Mes cerrado anterior", + "previous-week": "Semana anterior", + "previous-month": "Mes anterior", + }; + const normalized = String(windowKind || "").trim(); + return labels[normalized] || normalized; + } + + function renderStatsKpmMetric(data) { + if (data?.kpm_status !== "ready") { + return ""; + } + const kpmValue = safeParseNumber(data?.kpm); + if (!Number.isFinite(kpmValue)) { + return ""; + } + return ` +

+ KPM + ${safeDecimal(kpmValue, 2, "0.00")} +
+ `; + } + + function renderStatsExternalProfilesCard(profileData, brands) { + const links = resolveStatsExternalProfileLinks(profileData, brands); + return ` +
+

Perfiles externos

+ ${ + links.length + ? ` + + ` + : "

Perfiles externos no disponibles.

" + } +
+ `; + } + + function resolveStatsExternalProfileLinks(profileData, brands) { + const links = []; + const fallbackLinks = buildFallbackExternalProfileLinks(profileData); + const payloadLinks = + profileData?.external_profile_links && typeof profileData.external_profile_links === "object" + ? profileData.external_profile_links + : {}; + const candidateLinks = { + ...fallbackLinks, + ...payloadLinks, + }; + ["steam", "hellor", "hll_records", "helo"].forEach((key) => { + const href = candidateLinks?.[key]; + if (typeof href !== "string" || !href.trim()) { + return; + } + const brand = brands[key]; + links.push({ + href, + label: brand?.label || key, + logoSrc: brand?.logoSrc || "", + }); + }); + return links; + } + + function buildFallbackExternalProfileLinks(profileData) { + const playerId = String(profileData?.player_id || "").trim(); + const steamId = String(profileData?.steam_id_64 || playerId).trim(); + if (/^\d{17}$/.test(steamId)) { + return { + steam: `https://steamcommunity.com/profiles/${steamId}`, + hellor: `https://hellor.pro/player/${steamId}`, + hll_records: `https://hllrecords.com/profiles/${steamId}`, + helo: `https://helo-system.de/statistics/players/${steamId}?series=2024`, + }; + } + const epicId = String(profileData?.epic_id || playerId).trim().toLowerCase(); + if (/^[0-9a-f]{32}$/i.test(epicId)) { + return { + hellor: `https://hellor.pro/player/${epicId}`, + hll_records: `https://hllrecords.com/profiles/${epicId}`, + }; + } + return {}; + } + function safeInt(value, fallback = 0) { const parsed = Number(value); if (!Number.isFinite(parsed)) {