feat: add scoreboard and player external links
This commit is contained in:
@@ -0,0 +1,141 @@
|
|||||||
|
---
|
||||||
|
id: TASK-146
|
||||||
|
title: Recover match scoreboard and player external links
|
||||||
|
status: done
|
||||||
|
type: backend
|
||||||
|
team: Backend Senior
|
||||||
|
supporting_teams: [Frontend Senior]
|
||||||
|
roadmap_item: foundation
|
||||||
|
priority: high
|
||||||
|
---
|
||||||
|
|
||||||
|
# TASK-146 - Recover match scoreboard and player external links
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Restore the safe public scoreboard action on the internal match detail page and
|
||||||
|
expose player external profile links only when captured player identifiers make
|
||||||
|
that reliable.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The internal `historico-partida.html` detail payload can carry a safe
|
||||||
|
`match_url`, but users do not currently see the detail-page scoreboard action.
|
||||||
|
The same detail page has click-open player panels that can link to Steam and
|
||||||
|
SteamID64-based third-party profiles when the RCON/profile or reliable
|
||||||
|
historical data path already holds a valid SteamID64.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. Inspect the listed files and captured identifier path first.
|
||||||
|
2. Keep scoreboard actions detail-page only and add profile links only from
|
||||||
|
reliable identifiers.
|
||||||
|
3. Validate backend diagnostics, frontend syntax, integration/container checks
|
||||||
|
and rendered detail-page behavior.
|
||||||
|
|
||||||
|
## Files to Read First
|
||||||
|
|
||||||
|
- `ai/architecture-index.md`
|
||||||
|
- `ai/repo-context.md`
|
||||||
|
- `ai/orchestrator/backend-senior.md`
|
||||||
|
- `ai/orchestrator/frontend-senior.md`
|
||||||
|
- `backend/app/rcon_historical_read_model.py`
|
||||||
|
- `frontend/assets/js/historico-partida.js`
|
||||||
|
|
||||||
|
## Expected Files to Modify
|
||||||
|
|
||||||
|
- Relevant backend read-model, storage helper, diagnostics and tests for exposed
|
||||||
|
external player identifiers and profile links.
|
||||||
|
- `frontend/assets/js/historico-partida.js`
|
||||||
|
- Relevant detail-page CSS when profile links need styling.
|
||||||
|
- `ai/tasks/done/TASK-146-match-detail-scoreboard-and-player-links.md`
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Do not invent SteamIDs or use player names to build external links.
|
||||||
|
- Do not call Steam Web API or require new external credentials.
|
||||||
|
- Keep public scoreboard actions off recent match cards.
|
||||||
|
- Allow only safe Comunidad Hispana scoreboard match URLs in the detail page.
|
||||||
|
- Preserve click-open player panels and existing expanded stat sections.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- `python -m compileall backend/app`
|
||||||
|
- `python -m app.storage_diagnostics`
|
||||||
|
- `node --check frontend/assets/js/historico-partida.js`
|
||||||
|
- `node --check frontend/assets/js/historico.js`
|
||||||
|
- `node --check frontend/assets/js/historico-recent-live.js`
|
||||||
|
- `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1`
|
||||||
|
- `docker compose --profile advanced up -d --build backend frontend`
|
||||||
|
- Inspect the provided match-detail API response with `Invoke-WebRequest`.
|
||||||
|
- Render-check the detail-page scoreboard action, player panel profile state and
|
||||||
|
recent-match-card absence of public scoreboard links.
|
||||||
|
- Review `git diff --name-only` against task scope.
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
Implemented.
|
||||||
|
|
||||||
|
- The internal match detail scoreboard action now sits immediately after the
|
||||||
|
summary block and before the potentially long player table. It still uses the
|
||||||
|
existing detail-page safe scoreboard allowlist and keeps the established
|
||||||
|
`Ver en Scoreboard` regression-checked label.
|
||||||
|
- RCON detail rows derive `platform`, `steam_id_64` and Steam/Hellor/HLL Records
|
||||||
|
URLs from captured `player_id` only when it is a valid numeric 17-digit
|
||||||
|
SteamID64. Raw RCON `player_id` is still not exposed.
|
||||||
|
- Trusted public-scoreboard detail rows derive the same profile fields from
|
||||||
|
persisted `historical_players.steam_id`.
|
||||||
|
- Epic-style hex RCON IDs resolve to `platform: epic` with no Steam-only links;
|
||||||
|
other missing or unsupported IDs return `platform: unknown` with no external
|
||||||
|
link fields.
|
||||||
|
- Click-open player panels now include `Perfiles externos`. Steam-backed players
|
||||||
|
get the three external links with new-tab noopener rel attributes; missing-ID
|
||||||
|
panels show a clear unavailable state without using player names to build
|
||||||
|
URLs.
|
||||||
|
- PostgreSQL storage diagnostics now report SteamID64 availability counts for
|
||||||
|
RCON match rows, RCON profile snapshots and trusted scoreboard player rows.
|
||||||
|
|
||||||
|
Validation passed:
|
||||||
|
|
||||||
|
- `python -m compileall backend/app`
|
||||||
|
- `python -m app.storage_diagnostics` from `backend/` in local SQLite fallback
|
||||||
|
mode. It reports PostgreSQL external-ID diagnostics as inactive when the
|
||||||
|
PostgreSQL env is not configured locally.
|
||||||
|
- `docker compose exec backend python -m app.storage_diagnostics` after the
|
||||||
|
advanced stack build. PostgreSQL diagnostics reported:
|
||||||
|
- `rcon_match_steam_id64_rows`: `2554`
|
||||||
|
- `rcon_profile_steam_id64_rows`: `462`
|
||||||
|
- `scoreboard_player_steam_id64_rows`: `87369`
|
||||||
|
- `node --check frontend/assets/js/historico-partida.js`
|
||||||
|
- `node --check frontend/assets/js/historico.js`
|
||||||
|
- `node --check frontend/assets/js/historico-recent-live.js`
|
||||||
|
- Targeted backend tests from `backend/`:
|
||||||
|
`python -m unittest tests.test_rcon_materialization_pipeline tests.test_scoreboard_match_links`
|
||||||
|
- `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1`
|
||||||
|
- `docker compose --profile advanced up -d --build backend frontend`
|
||||||
|
- The provided `Invoke-WebRequest` match-detail API check returned the safe
|
||||||
|
Carentan scoreboard URL plus validated external profile links for captured
|
||||||
|
SteamID64-backed players.
|
||||||
|
- Rendered local Chrome/Selenium fallback QA verified:
|
||||||
|
- detail scoreboard action appears before the player table and opens
|
||||||
|
`https://scoreboard.comunidadhll.es:5443/games/1562094`
|
||||||
|
- Steam-backed player panel shows Steam, Hellor and HLL Records links
|
||||||
|
- a no-SteamID64 player panel shows `Perfiles externos no disponibles`
|
||||||
|
- recent `historico.html` match cards show no public scoreboard links
|
||||||
|
- mobile detail layout keeps the scoreboard action readable
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- Browser plugin runtime tools were not exposed by tool discovery in this
|
||||||
|
session, so rendered QA used temporary headless Chrome/Selenium checks and
|
||||||
|
screenshots outside the repository.
|
||||||
|
- Targeted backend unittest output still includes existing SQLite resource
|
||||||
|
warnings from the current test suite while tests pass.
|
||||||
|
- Browser console output on rendered checks only reported the existing missing
|
||||||
|
`favicon.ico` request.
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -17,6 +17,7 @@ from .config import (
|
|||||||
from .historical_models import HistoricalServerDefinition
|
from .historical_models import HistoricalServerDefinition
|
||||||
from .monthly_mvp import build_monthly_mvp_rankings
|
from .monthly_mvp import build_monthly_mvp_rankings
|
||||||
from .monthly_mvp_v2 import build_monthly_mvp_v2_rankings
|
from .monthly_mvp_v2 import build_monthly_mvp_v2_rankings
|
||||||
|
from .player_external_profiles import build_external_player_profile_fields
|
||||||
from .scoreboard_origins import (
|
from .scoreboard_origins import (
|
||||||
list_trusted_public_scoreboard_origins,
|
list_trusted_public_scoreboard_origins,
|
||||||
resolve_trusted_scoreboard_match_url,
|
resolve_trusted_scoreboard_match_url,
|
||||||
@@ -872,6 +873,7 @@ def get_historical_match_detail(
|
|||||||
SELECT
|
SELECT
|
||||||
historical_players.display_name,
|
historical_players.display_name,
|
||||||
historical_players.stable_player_key,
|
historical_players.stable_player_key,
|
||||||
|
historical_players.steam_id,
|
||||||
historical_player_match_stats.team_side,
|
historical_player_match_stats.team_side,
|
||||||
historical_player_match_stats.level,
|
historical_player_match_stats.level,
|
||||||
historical_player_match_stats.kills,
|
historical_player_match_stats.kills,
|
||||||
@@ -925,6 +927,7 @@ def get_historical_match_detail(
|
|||||||
"name": player_row["display_name"],
|
"name": player_row["display_name"],
|
||||||
"stable_player_key": player_row["stable_player_key"],
|
"stable_player_key": player_row["stable_player_key"],
|
||||||
"team_side": player_row["team_side"],
|
"team_side": player_row["team_side"],
|
||||||
|
**build_external_player_profile_fields(steam_id=player_row["steam_id"]),
|
||||||
"level": _coerce_int(player_row["level"]),
|
"level": _coerce_int(player_row["level"]),
|
||||||
"kills": _coerce_int(player_row["kills"]),
|
"kills": _coerce_int(player_row["kills"]),
|
||||||
"deaths": _coerce_int(player_row["deaths"]),
|
"deaths": _coerce_int(player_row["deaths"]),
|
||||||
|
|||||||
43
backend/app/player_external_profiles.py
Normal file
43
backend/app/player_external_profiles.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
"""Safe external profile fields derived from captured player identifiers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
_STEAM_ID64_RE = re.compile(r"^\d{17}$")
|
||||||
|
_EPIC_ID_RE = re.compile(r"^[0-9a-f]{32}$", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def build_external_player_profile_fields(
|
||||||
|
*,
|
||||||
|
player_id: object = None,
|
||||||
|
steam_id: object = None,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
"""Expose player profile links only when a captured SteamID64 is valid."""
|
||||||
|
steam_id_64 = normalize_steam_id_64(steam_id) or normalize_steam_id_64(player_id)
|
||||||
|
if steam_id_64:
|
||||||
|
return {
|
||||||
|
"steam_id_64": steam_id_64,
|
||||||
|
"platform": "steam",
|
||||||
|
"external_profile_links": {
|
||||||
|
"steam": f"https://steamcommunity.com/profiles/{steam_id_64}",
|
||||||
|
"hellor": f"https://hellor.pro/player/{steam_id_64}",
|
||||||
|
"hll_records": f"https://hllrecords.com/profiles/{steam_id_64}",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return {"platform": infer_player_platform(player_id=player_id, steam_id=steam_id)}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_steam_id_64(value: object) -> str | None:
|
||||||
|
normalized = str(value or "").strip()
|
||||||
|
return normalized if _STEAM_ID64_RE.fullmatch(normalized) else None
|
||||||
|
|
||||||
|
|
||||||
|
def infer_player_platform(*, player_id: object = None, steam_id: object = None) -> str:
|
||||||
|
normalized_player_id = str(player_id or "").strip()
|
||||||
|
if normalize_steam_id_64(steam_id) or normalize_steam_id_64(normalized_player_id):
|
||||||
|
return "steam"
|
||||||
|
if _EPIC_ID_RE.fullmatch(normalized_player_id):
|
||||||
|
return "epic"
|
||||||
|
return "unknown"
|
||||||
@@ -10,6 +10,7 @@ from typing import Any, Iterable, Mapping
|
|||||||
|
|
||||||
from .config import get_database_url, get_historical_weekly_fallback_max_weekday
|
from .config import get_database_url, get_historical_weekly_fallback_max_weekday
|
||||||
from .historical_models import HistoricalSnapshotRecord
|
from .historical_models import HistoricalSnapshotRecord
|
||||||
|
from .player_external_profiles import build_external_player_profile_fields
|
||||||
from .scoreboard_origins import resolve_trusted_scoreboard_match_url
|
from .scoreboard_origins import resolve_trusted_scoreboard_match_url
|
||||||
|
|
||||||
|
|
||||||
@@ -518,7 +519,7 @@ def get_scoreboard_match_detail(*, server_slug: str, match_id: str) -> dict[str,
|
|||||||
return None
|
return None
|
||||||
players = connection.execute(
|
players = connection.execute(
|
||||||
"""
|
"""
|
||||||
SELECT hp.display_name, hp.stable_player_key, stats.team_side, stats.level,
|
SELECT hp.display_name, hp.stable_player_key, hp.steam_id, stats.team_side, stats.level,
|
||||||
stats.kills, stats.deaths, stats.teamkills, stats.combat, stats.offense,
|
stats.kills, stats.deaths, stats.teamkills, stats.combat, stats.offense,
|
||||||
stats.defense, stats.support, stats.time_seconds
|
stats.defense, stats.support, stats.time_seconds
|
||||||
FROM historical_player_match_stats AS stats
|
FROM historical_player_match_stats AS stats
|
||||||
@@ -546,6 +547,7 @@ def get_scoreboard_match_detail(*, server_slug: str, match_id: str) -> dict[str,
|
|||||||
"name": player["display_name"],
|
"name": player["display_name"],
|
||||||
"stable_player_key": player["stable_player_key"],
|
"stable_player_key": player["stable_player_key"],
|
||||||
"team_side": player["team_side"],
|
"team_side": player["team_side"],
|
||||||
|
**build_external_player_profile_fields(steam_id=player["steam_id"]),
|
||||||
**{
|
**{
|
||||||
key: _int(player[key])
|
key: _int(player[key])
|
||||||
for key in (
|
for key in (
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from datetime import datetime, timedelta, timezone
|
|||||||
|
|
||||||
from .historical_storage import ALL_SERVERS_SLUG
|
from .historical_storage import ALL_SERVERS_SLUG
|
||||||
from .normalizers import normalize_map_name
|
from .normalizers import normalize_map_name
|
||||||
|
from .player_external_profiles import build_external_player_profile_fields
|
||||||
from .rcon_scoreboard_correlation import resolve_rcon_scoreboard_match_url
|
from .rcon_scoreboard_correlation import resolve_rcon_scoreboard_match_url
|
||||||
from .rcon_historical_storage import (
|
from .rcon_historical_storage import (
|
||||||
find_rcon_historical_competitive_window,
|
find_rcon_historical_competitive_window,
|
||||||
@@ -319,6 +320,7 @@ def _build_player_row(
|
|||||||
"top_weapons": _top_counter(row.get("weapons_json")),
|
"top_weapons": _top_counter(row.get("weapons_json")),
|
||||||
"most_killed": _top_counter(row.get("most_killed_json")),
|
"most_killed": _top_counter(row.get("most_killed_json")),
|
||||||
"death_by": _top_counter(row.get("death_by_json")),
|
"death_by": _top_counter(row.get("death_by_json")),
|
||||||
|
**build_external_player_profile_fields(player_id=row.get("player_id")),
|
||||||
}
|
}
|
||||||
if profile_summary:
|
if profile_summary:
|
||||||
player["profile_summary"] = profile_summary
|
player["profile_summary"] = profile_summary
|
||||||
|
|||||||
@@ -99,6 +99,12 @@ def build_storage_diagnostics() -> dict[str, object]:
|
|||||||
"paused Elo/MMR tables",
|
"paused Elo/MMR tables",
|
||||||
],
|
],
|
||||||
"scoreboard_correlation": "PostgreSQL safe candidates and migrated trusted historical match URLs are used.",
|
"scoreboard_correlation": "PostgreSQL safe candidates and migrated trusted historical match URLs are used.",
|
||||||
|
"external_player_ids": _postgres_external_player_id_diagnostics()
|
||||||
|
if backend == "postgresql"
|
||||||
|
else {
|
||||||
|
"available_in_postgresql": False,
|
||||||
|
"reason": "PostgreSQL storage is not active.",
|
||||||
|
},
|
||||||
"migration_parity_summary": {
|
"migration_parity_summary": {
|
||||||
"available": backend == "postgresql",
|
"available": backend == "postgresql",
|
||||||
"source_command": "python -m app.sqlite_to_postgres_migration",
|
"source_command": "python -m app.sqlite_to_postgres_migration",
|
||||||
@@ -125,6 +131,31 @@ def _count_sqlite_tables() -> dict[str, int]:
|
|||||||
return counts
|
return counts
|
||||||
|
|
||||||
|
|
||||||
|
def _postgres_external_player_id_diagnostics() -> dict[str, object]:
|
||||||
|
from .postgres_rcon_storage import connect_postgres
|
||||||
|
|
||||||
|
with connect_postgres() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
(SELECT COUNT(*) FROM rcon_match_player_stats
|
||||||
|
WHERE player_id ~ '^[0-9]{17}$') AS rcon_match_steam_id64_rows,
|
||||||
|
(SELECT COUNT(*) FROM rcon_player_profile_snapshots
|
||||||
|
WHERE player_id ~ '^[0-9]{17}$') AS rcon_profile_steam_id64_rows,
|
||||||
|
(SELECT COUNT(*) FROM historical_players
|
||||||
|
WHERE steam_id ~ '^[0-9]{17}$') AS scoreboard_player_steam_id64_rows
|
||||||
|
"""
|
||||||
|
).fetchone()
|
||||||
|
return {
|
||||||
|
"available_in_postgresql": True,
|
||||||
|
"rcon_match_steam_id64_rows": int(row["rcon_match_steam_id64_rows"] or 0),
|
||||||
|
"rcon_profile_steam_id64_rows": int(row["rcon_profile_steam_id64_rows"] or 0),
|
||||||
|
"scoreboard_player_steam_id64_rows": int(
|
||||||
|
row["scoreboard_player_steam_id64_rows"] or 0
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
print(json.dumps(build_storage_diagnostics(), ensure_ascii=False, indent=2, default=str))
|
print(json.dumps(build_storage_diagnostics(), ensure_ascii=False, indent=2, default=str))
|
||||||
|
|
||||||
|
|||||||
@@ -74,8 +74,18 @@ class RconMaterializationPipelineTests(unittest.TestCase):
|
|||||||
self.assertEqual(detail["result_source"], "admin-log-match-ended")
|
self.assertEqual(detail["result_source"], "admin-log-match-ended")
|
||||||
self.assertEqual(detail["result"]["allied_score"], 5)
|
self.assertEqual(detail["result"]["allied_score"], 5)
|
||||||
self.assertEqual(detail["timestamp_confidence"], "absolute")
|
self.assertEqual(detail["timestamp_confidence"], "absolute")
|
||||||
self.assertNotIn("player_id", detail["players"][0])
|
players = {row["player_name"]: row for row in detail["players"]}
|
||||||
self.assertIn("kd_ratio", detail["players"][0])
|
self.assertNotIn("player_id", players["Alpha"])
|
||||||
|
self.assertIn("kd_ratio", players["Alpha"])
|
||||||
|
self.assertEqual(players["Alpha"]["steam_id_64"], "76561198000000001")
|
||||||
|
self.assertEqual(players["Alpha"]["platform"], "steam")
|
||||||
|
self.assertEqual(
|
||||||
|
players["Alpha"]["external_profile_links"]["hellor"],
|
||||||
|
"https://hellor.pro/player/76561198000000001",
|
||||||
|
)
|
||||||
|
self.assertEqual(players["Charlie"]["platform"], "unknown")
|
||||||
|
self.assertNotIn("steam_id_64", players["Charlie"])
|
||||||
|
self.assertNotIn("external_profile_links", players["Charlie"])
|
||||||
gc.collect()
|
gc.collect()
|
||||||
|
|
||||||
def test_match_detail_marks_equal_materialized_timestamps_as_server_time_only(self) -> None:
|
def test_match_detail_marks_equal_materialized_timestamps_as_server_time_only(self) -> None:
|
||||||
|
|||||||
@@ -70,6 +70,40 @@ class PersistedScoreboardMatchLinkTests(unittest.TestCase):
|
|||||||
self.assertIsNone(detail["match_url"])
|
self.assertIsNone(detail["match_url"])
|
||||||
gc.collect()
|
gc.collect()
|
||||||
|
|
||||||
|
def test_detail_player_links_use_trusted_scoreboard_steam_id(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
db_path = Path(tmpdir) / "historical.sqlite3"
|
||||||
|
_persist_match(
|
||||||
|
db_path,
|
||||||
|
server_slug="comunidad-hispana-02",
|
||||||
|
match_id="steam-player-match",
|
||||||
|
player_stats=[
|
||||||
|
{
|
||||||
|
"player": "Steam Player",
|
||||||
|
"steaminfo": {"profile": {"steamid": "76561198000000009"}},
|
||||||
|
"team": {"side": "allies"},
|
||||||
|
"kills": 4,
|
||||||
|
"deaths": 2,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
detail = get_historical_match_detail(
|
||||||
|
server_slug="comunidad-hispana-02",
|
||||||
|
match_id="steam-player-match",
|
||||||
|
db_path=db_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIsNotNone(detail)
|
||||||
|
player = detail["players"][0]
|
||||||
|
self.assertEqual(player["steam_id_64"], "76561198000000009")
|
||||||
|
self.assertEqual(player["platform"], "steam")
|
||||||
|
self.assertEqual(
|
||||||
|
player["external_profile_links"]["hll_records"],
|
||||||
|
"https://hllrecords.com/profiles/76561198000000009",
|
||||||
|
)
|
||||||
|
gc.collect()
|
||||||
|
|
||||||
def test_rcon_match_detail_does_not_fabricate_external_scoreboard_url(self) -> None:
|
def test_rcon_match_detail_does_not_fabricate_external_scoreboard_url(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
db_path = Path(tmpdir) / "historical.sqlite3"
|
db_path = Path(tmpdir) / "historical.sqlite3"
|
||||||
@@ -176,6 +210,7 @@ def _persist_match(
|
|||||||
map_name: str = "carentan",
|
map_name: str = "carentan",
|
||||||
started_at: str = "2026-05-01T10:00:00Z",
|
started_at: str = "2026-05-01T10:00:00Z",
|
||||||
ended_at: str = "2026-05-01T11:20:00Z",
|
ended_at: str = "2026-05-01T11:20:00Z",
|
||||||
|
player_stats: list[dict[str, object]] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
upsert_historical_match(
|
upsert_historical_match(
|
||||||
server_slug=server_slug,
|
server_slug=server_slug,
|
||||||
@@ -186,7 +221,7 @@ def _persist_match(
|
|||||||
"end": ended_at,
|
"end": ended_at,
|
||||||
"map": {"name": map_name},
|
"map": {"name": map_name},
|
||||||
"result": {"allied": 3, "axis": 2},
|
"result": {"allied": 3, "axis": 2},
|
||||||
"player_stats": [],
|
"player_stats": player_stats or [],
|
||||||
},
|
},
|
||||||
db_path=db_path,
|
db_path=db_path,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -197,6 +197,37 @@
|
|||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.historical-player-stats-panel__profiles {
|
||||||
|
padding: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historical-player-profile-links {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historical-player-profile-links a {
|
||||||
|
display: inline-flex;
|
||||||
|
min-height: 32px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 11px;
|
||||||
|
border: 1px solid rgba(210, 182, 118, 0.34);
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--accent-warm);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-decoration: none;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historical-player-profile-links a:hover,
|
||||||
|
.historical-player-profile-links a:focus-visible {
|
||||||
|
border-color: rgba(210, 182, 118, 0.62);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
.historical-player-stats-panel__section h5 {
|
.historical-player-stats-panel__section h5 {
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
|
|||||||
@@ -324,6 +324,7 @@ function renderPlayerStatsPanel(player, item, context) {
|
|||||||
${renderPlayerStatChip("KPM", context.kpm)}
|
${renderPlayerStatChip("KPM", context.kpm)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
${renderExternalProfilesSection(player)}
|
||||||
${
|
${
|
||||||
hasExpandedStats
|
hasExpandedStats
|
||||||
? `
|
? `
|
||||||
@@ -340,6 +341,39 @@ function renderPlayerStatsPanel(player, item, context) {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderExternalProfilesSection(player) {
|
||||||
|
const links = [
|
||||||
|
["steam", "Steam"],
|
||||||
|
["hellor", "Hellor"],
|
||||||
|
["hll_records", "HLL Records"],
|
||||||
|
]
|
||||||
|
.map(([key, label]) => [label, player.external_profile_links?.[key]])
|
||||||
|
.filter(([, href]) => typeof href === "string" && href.trim());
|
||||||
|
|
||||||
|
return `
|
||||||
|
<article class="historical-player-stats-panel__section historical-player-stats-panel__profiles">
|
||||||
|
<h5>Perfiles externos</h5>
|
||||||
|
${
|
||||||
|
links.length
|
||||||
|
? `
|
||||||
|
<div class="historical-player-profile-links">
|
||||||
|
${links
|
||||||
|
.map(
|
||||||
|
([label, href]) => `
|
||||||
|
<a href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer">
|
||||||
|
${escapeHtml(label)}
|
||||||
|
</a>
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.join("")}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: "<p>Perfiles externos no disponibles.</p>"
|
||||||
|
}
|
||||||
|
</article>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
function renderPlayerStatChip(label, value) {
|
function renderPlayerStatChip(label, value) {
|
||||||
return `
|
return `
|
||||||
<article>
|
<article>
|
||||||
@@ -454,6 +488,7 @@ function renderActions(item, actionsNode) {
|
|||||||
actionsNode.innerHTML = `
|
actionsNode.innerHTML = `
|
||||||
<a
|
<a
|
||||||
class="historical-match-card__link"
|
class="historical-match-card__link"
|
||||||
|
data-match-detail-scoreboard-link
|
||||||
href="${escapeHtml(matchUrl)}"
|
href="${escapeHtml(matchUrl)}"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
|
|||||||
@@ -76,6 +76,7 @@
|
|||||||
Cargando detalle...
|
Cargando detalle...
|
||||||
</p>
|
</p>
|
||||||
<div class="historical-summary-grid" id="match-detail-grid" hidden></div>
|
<div class="historical-summary-grid" id="match-detail-grid" hidden></div>
|
||||||
|
<div class="historical-match-card__actions" id="match-detail-actions" hidden></div>
|
||||||
<div class="historical-detail-section" id="match-detail-players-section" hidden>
|
<div class="historical-detail-section" id="match-detail-players-section" hidden>
|
||||||
<div class="historical-detail-section__header">
|
<div class="historical-detail-section__header">
|
||||||
<div>
|
<div>
|
||||||
@@ -104,7 +105,6 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="historical-match-card__actions" id="match-detail-actions" hidden></div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
Reference in New Issue
Block a user