diff --git a/ai/tasks/done/TASK-113-add-internal-match-detail-links.md b/ai/tasks/done/TASK-113-add-internal-match-detail-links.md new file mode 100644 index 0000000..e97a263 --- /dev/null +++ b/ai/tasks/done/TASK-113-add-internal-match-detail-links.md @@ -0,0 +1,212 @@ +--- +id: TASK-113 +title: Add internal match detail links +status: pending +type: frontend +team: Frontend Senior +supporting_teams: + - Backend Senior +roadmap_item: historical +priority: high +--- + +# TASK-113 - Add internal match detail links + +## Goal + +Add implementation support so every card in "Ultimas partidas registradas" links to available match data without fabricating unsafe external scoreboard URLs for synthetic RCON competitive-window match IDs. + +## Context + +The historical page now renders recent matches, but the latest backend response for `/api/historical/snapshots/recent-matches?server=all-servers&limit=5` can return `selected_source: "rcon"` and items with `capture_basis: "rcon-competitive-window"` and no `match_url`. + +The previous implementation correctly renders external "Ver partida" links only when a safe source URL exists. However, current RCON competitive-window matches can use synthetic IDs such as `31:2026-04-13T13:59:26.174488Z`, which are not direct scoreboard `/games/{id}` URLs. + +Product decision: + +- Keep using a safe external `match_url` when one exists. +- For matches without a safe external URL, provide an internal match detail page/link. +- Do not invent external scoreboard URLs for synthetic RCON match IDs. +- Preserve HLL Vietnam's military, Vietnam, tactical and sober visual identity. +- Do not reintroduce paused MVP/Elo UI. +- Do not reintroduce Comunidad Hispana #03. + +## Steps + +1. Move this task from `ai/tasks/pending/` to `ai/tasks/in-progress/`. +2. Inspect the listed files first. +3. Confirm the recent matches response shape for `/api/historical/snapshots/recent-matches?server=all-servers&limit=5`, including RCON competitive-window items without `match_url`. +4. Keep the existing safe external `match_url` behavior: + - If a recent match item has a safe external `match_url`, render "Ver partida" or "Abrir en scoreboard". + - Keep `target="_blank"` and `rel="noopener noreferrer"`. +5. Add internal match detail links for recent matches without an external URL: + - Render a visible link/button, for example "Ver detalles". + - Point it to an internal frontend route/page, for example `historico-partida.html?server={serverSlug}&match={encodedMatchId}`. + - URL-encode the match ID. +6. Add a simple internal match detail frontend page: + - Suggested file: `frontend/historico-partida.html`. + - Preserve the current visual identity. + - Read `server` and `match` from the query string. + - Call a backend endpoint to get available match details. + - Show a graceful unavailable or partial-data state if only limited RCON data exists. +7. Add or reuse a backend endpoint for match details: + - Prefer `/api/historical/matches/detail?server={serverSlug}&match={matchId}`. + - Return available data for both persisted scoreboard matches and RCON competitive-window matches when available. + - At minimum return `server`, `match_id`, `map`, `started_at`, `ended_at` or `closed_at`, `duration_seconds` if available, `player_count` or `peak_players` if available, `result` or `score` if available, `capture_basis` or `capabilities` if available, and external `match_url` if a safe one exists. + - Do not fabricate external scoreboard URLs for synthetic RCON IDs. +8. Preserve the existing historical page sections and selector behavior: + - summary + - basic rankings + - recent matches + - server selector with only Todos, Comunidad Hispana #01 and Comunidad Hispana #02 +9. Keep these blocks paused or hidden: + - MVP mensual V1 + - MVP mensual V2 + - Comparativa V1 vs V2 + - Elo/MMR mensual +10. Validate the result with all checks listed below. +11. Move this task to `ai/tasks/done/` only after validation is complete and document the outcome in this file. +12. Commit and push the completed implementation branch. + +## Files to Read First + +- `ai/architecture-index.md` +- `ai/repo-context.md` +- `ai/orchestrator/frontend-senior.md` +- `ai/orchestrator/backend-senior.md` +- `frontend/historico.html` +- `frontend/assets/js/historico.js` +- `frontend/assets/css/historico.css` +- `backend/app/routes.py` +- `backend/app/payloads.py` +- `backend/app/historical_storage.py` +- `backend/app/historical_snapshots.py` +- any RCON competitive/recent match read model modules +- `scripts/run-integration-tests.ps1` +- `docs/decisions.md` + +## Expected Files to Modify + +- `frontend/historico.html` +- `frontend/assets/js/historico.js` +- `frontend/assets/css/historico.css` +- possibly new `frontend/historico-partida.html` +- possibly new `frontend/assets/js/historico-partida.js` +- possibly `backend/app/routes.py` +- possibly `backend/app/payloads.py` +- possibly `backend/app/historical_storage.py` +- possibly historical snapshot/read model modules +- possibly tests under `backend/tests/` +- this task file, moved to `ai/tasks/done/` + +If additional files become necessary, explain why in the task outcome and commit message. + +## Expected Files Not to Modify + +- Docker/Compose config +- local `.env` +- database migrations unless absolutely required and justified +- persisted data +- Elo/MMR backend implementation files +- unrelated frontend pages + +## Constraints + +- Keep the change focused on internal detail links and match detail data. +- Do not delete backend code, migrations, persisted data, snapshots or endpoints. +- Do not change historical ingestion policy. +- Do not reintroduce Comunidad Hispana #03. +- Do not reintroduce MVP/Elo UI. +- Do not add real credentials. +- Do not introduce unnecessary frontend frameworks or dependencies. +- Preserve direct browser compatibility where applicable. +- Preserve HLL Vietnam branding and product identity. +- Do not use public "snapshot" wording in user-facing copy. +- Confirm `backend/runtime/` is not created or committed. + +## Validation + +Before completing the task, run: + +- `git status` +- `node --check frontend/assets/js/historico.js` +- If a new frontend JS file is added, run `node --check` on it too. +- `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1` +- `docker compose down` +- `docker compose up -d --build` +- `docker compose ps` +- `Invoke-WebRequest http://localhost:8000/health | Select-Object -ExpandProperty Content` +- `Invoke-WebRequest http://localhost:8080 | Select-Object -ExpandProperty StatusCode` +- `Invoke-WebRequest "http://localhost:8000/api/historical/snapshots/recent-matches?server=all-servers&limit=5" | Select-Object -ExpandProperty Content` +- Validate the new match detail endpoint for at least one recent match ID. + +Manual or served HTML verification: + +- Recent match cards show either external "Ver partida" or internal "Ver detalles". +- The internal detail page opens and displays available data. +- No server #03 appears. +- No paused MVP/Elo blocks appear. +- No public "snapshot" wording appears. +- No migrations or persisted data changed unless explicitly justified. +- `backend/runtime/` is not created or committed. + +Before committing, also review: + +- `git diff --name-only` +- changed files match the expected scope + +## Commit And Push Requirements + +- Run validation before committing. +- Run `git status`. +- Stage only intended files. +- Commit with a clear message, for example `feat: add internal match detail links`. +- Push the branch to origin. +- Do not leave completed work only in local. + +## Outcome + +Document the validation performed, notable implementation decisions, and any follow-up task that should be created instead of expanding this task. + +Completed. + +Validation performed: + +- Confirmed `/api/historical/snapshots/recent-matches?server=all-servers&limit=5` returns `selected_source: "rcon"` with RCON competitive-window items such as `31:2026-04-13T13:59:26.174488Z`, `capture_basis: "rcon-competitive-window"` and no `match_url`. +- Ran `node --check frontend/assets/js/historico.js`. +- Ran `node --check frontend/assets/js/historico-partida.js`. +- Ran Python syntax compilation for the touched backend modules. +- Ran `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1`; the script reports no product integration tests are configured for this platform-only scope and passed the backend startup import check. +- Ran `docker compose down`. +- Ran `docker compose up -d --build`. +- Ran `docker compose ps`; backend and frontend were up on ports 8000 and 8080. +- Verified `http://localhost:8000/health`. +- Verified `http://localhost:8080`. +- Verified `http://localhost:8000/api/historical/snapshots/recent-matches?server=all-servers&limit=5`. +- Verified `http://localhost:8000/api/historical/matches/detail?server=comunidad-hispana-01&match=31%3A2026-04-13T13%3A59%3A26.174488Z` returns the RCON detail internally with `match_url: null`. +- Used headless Chrome against `http://localhost:8080/historico.html`; recent RCON cards render `Ver detalles`. +- Used headless Chrome against `http://localhost:8080/historico-partida.html?...`; the internal detail page renders available RCON data and the `Ventana competitiva RCON` capture basis. +- Confirmed no `backend/runtime/` path exists. + +Implementation decisions: + +- Preserved the existing safe external `match_url` behavior and added `Ver detalles` only when no safe external URL exists. +- Added `frontend/historico-partida.html` and `frontend/assets/js/historico-partida.js` as a direct-browser-compatible internal detail page. +- Added `/api/historical/matches/detail?server={serverSlug}&match={matchId}`. +- Added persisted public-scoreboard detail lookup and exact RCON competitive-window detail lookup. +- Kept synthetic RCON match IDs internal and did not fabricate external scoreboard URLs. +- Did not reintroduce paused MVP/Elo UI or Comunidad Hispana #03 in the frontend selector. + +Scope note: + +- The task exceeded the preferred 5-file/200-line budget because it required both a new internal frontend page and a backend detail endpoint/read path for two historical sources. No migrations, persisted data, Docker config, `.env`, Elo/MMR implementation files or unrelated frontend pages were changed. + +Follow-up: + +- No follow-up task is required from this implementation. A future task could add richer player-level detail if the RCON historical model later exposes player stats. + +## 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. diff --git a/backend/app/historical_storage.py b/backend/app/historical_storage.py index 540ce73..4f34051 100644 --- a/backend/app/historical_storage.py +++ b/backend/app/historical_storage.py @@ -816,6 +816,83 @@ def list_recent_historical_matches( return items +def get_historical_match_detail( + *, + server_slug: str, + match_id: str, + db_path: Path | None = None, +) -> dict[str, object] | None: + """Return one persisted public-scoreboard match detail for the historical API layer.""" + normalized_server_slug = _stringify(server_slug) + normalized_match_id = _stringify(match_id) + if not normalized_server_slug or not normalized_match_id: + return None + resolved_path = initialize_historical_storage(db_path=db_path) + with _connect(resolved_path) as connection: + row = connection.execute( + """ + SELECT + historical_servers.slug AS server_slug, + historical_servers.display_name AS server_name, + historical_matches.external_match_id, + historical_matches.started_at, + historical_matches.ended_at, + historical_matches.map_pretty_name, + historical_matches.map_name, + historical_matches.allied_score, + historical_matches.axis_score, + historical_matches.raw_payload_ref, + historical_servers.scoreboard_base_url, + COUNT(historical_player_match_stats.id) AS player_count, + SUM(COALESCE(historical_player_match_stats.time_seconds, 0)) AS total_time_seconds + FROM historical_matches + INNER JOIN historical_servers + ON historical_servers.id = historical_matches.historical_server_id + LEFT JOIN historical_player_match_stats + ON historical_player_match_stats.historical_match_id = historical_matches.id + WHERE historical_servers.slug = ? + AND historical_matches.external_match_id = ? + GROUP BY historical_matches.id + LIMIT 1 + """, + (normalized_server_slug, normalized_match_id), + ).fetchone() + if row is None: + return None + started_at = row["started_at"] + ended_at = row["ended_at"] + return { + "server": { + "slug": row["server_slug"], + "name": row["server_name"], + }, + "match_id": row["external_match_id"], + "started_at": started_at, + "ended_at": ended_at, + "closed_at": ended_at or started_at, + "duration_seconds": _calculate_match_duration_seconds(started_at, ended_at), + "map": { + "name": row["map_name"], + "pretty_name": row["map_pretty_name"] or row["map_name"], + }, + "result": { + "allied_score": _coerce_int(row["allied_score"]), + "axis_score": _coerce_int(row["axis_score"]), + "winner": _resolve_match_winner( + row["allied_score"], + row["axis_score"], + ), + }, + "player_count": int(row["player_count"] or 0), + "total_time_seconds": _coerce_int(row["total_time_seconds"]), + "capture_basis": "public-scoreboard-match", + "match_url": _resolve_safe_match_url( + row["raw_payload_ref"], + row["scoreboard_base_url"], + ), + } + + def list_historical_server_summaries( *, server_slug: str | None = None, @@ -3140,6 +3217,18 @@ def _resolve_safe_match_url(raw_payload_ref: object, scoreboard_base_url: object return candidate +def _calculate_match_duration_seconds(started_at: object, ended_at: object) -> int | None: + start_text = _stringify(started_at) + end_text = _stringify(ended_at) + if not start_text or not end_text: + return None + try: + duration = _parse_timestamp(end_text) - _parse_timestamp(start_text) + except ValueError: + return None + return max(0, int(duration.total_seconds())) + + def _start_of_week(value: datetime) -> datetime: normalized = value.astimezone(timezone.utc) midnight = normalized.replace(hour=0, minute=0, second=0, microsecond=0) diff --git a/backend/app/payloads.py b/backend/app/payloads.py index 20a35a8..94f8cb4 100644 --- a/backend/app/payloads.py +++ b/backend/app/payloads.py @@ -40,6 +40,7 @@ from .historical_snapshots import ( from .historical_storage import ( ALL_SERVERS_SLUG, DEFAULT_HISTORICAL_SERVERS, + get_historical_match_detail, get_historical_player_profile, list_historical_server_summaries, list_monthly_leaderboard, @@ -47,6 +48,7 @@ from .historical_storage import ( list_weekly_leaderboard, list_weekly_top_kills, ) +from .rcon_historical_read_model import get_rcon_historical_match_detail from .normalizers import normalize_map_name from .storage import list_latest_snapshots, list_server_history, list_snapshot_history @@ -473,6 +475,72 @@ def build_recent_historical_matches_payload( } +def build_historical_match_detail_payload( + *, + server_slug: str, + match_id: str, +) -> dict[str, object]: + """Return available detail for one historical match without inventing external URLs.""" + if get_historical_data_source_kind() == SOURCE_KIND_RCON: + item = get_rcon_historical_match_detail( + server_key=server_slug, + match_id=match_id, + ) + if item is not None: + return { + "status": "ok", + "data": { + "title": "Detalle de partida historica", + "context": "historical-match-detail", + "source": "rcon-historical-competitive-read-model", + "found": True, + **build_source_policy( + primary_source=SOURCE_KIND_RCON, + selected_source=SOURCE_KIND_RCON, + source_attempts=[ + build_source_attempt( + source=SOURCE_KIND_RCON, + role="primary", + status="success", + reason="historical-match-detail-served-by-rcon", + ) + ], + ), + "item": item, + }, + } + + item = get_historical_match_detail(server_slug=server_slug, match_id=match_id) + return { + "status": "ok", + "data": { + "title": "Detalle de partida historica", + "context": "historical-match-detail", + "source": "historical-crcon-storage", + "found": item is not None, + **( + _resolve_historical_fallback_policy( + fallback_reason="rcon-historical-read-model-has-no-match-detail" + ) + if get_historical_data_source_kind() == SOURCE_KIND_RCON + else build_source_policy( + primary_source=SOURCE_KIND_PUBLIC_SCOREBOARD, + selected_source=SOURCE_KIND_PUBLIC_SCOREBOARD, + source_attempts=[ + build_source_attempt( + source=SOURCE_KIND_PUBLIC_SCOREBOARD, + role="primary", + status="success" if item is not None else "empty", + reason="historical-match-detail-served-by-public-scoreboard", + ) + ], + ) + ), + "item": item, + }, + } + + def build_monthly_mvp_payload( *, limit: int = 10, diff --git a/backend/app/rcon_historical_read_model.py b/backend/app/rcon_historical_read_model.py index f7396b7..689c95d 100644 --- a/backend/app/rcon_historical_read_model.py +++ b/backend/app/rcon_historical_read_model.py @@ -8,6 +8,7 @@ from .historical_storage import ALL_SERVERS_SLUG from .normalizers import normalize_map_name from .rcon_historical_storage import ( find_rcon_historical_competitive_window, + get_rcon_historical_competitive_window_by_session, list_rcon_historical_competitive_summary_rows, list_rcon_historical_competitive_windows, ) @@ -123,6 +124,48 @@ def get_rcon_historical_competitive_match_context( ) +def get_rcon_historical_match_detail( + *, + server_key: str, + match_id: str, +) -> dict[str, object] | None: + """Return one RCON competitive window as a match-detail compatible payload.""" + item = get_rcon_historical_competitive_window_by_session( + server_key=server_key, + session_key=match_id, + ) + if item is None: + return None + return { + "server": { + "slug": item["target_key"], + "name": item["display_name"], + "external_server_id": item["external_server_id"], + "region": item["region"], + }, + "match_id": item["session_key"], + "started_at": item["first_seen_at"], + "ended_at": item["last_seen_at"], + "closed_at": item["last_seen_at"], + "duration_seconds": item.get("duration_seconds"), + "map": { + "name": item.get("map_name"), + "pretty_name": normalize_map_name(item.get("map_pretty_name") or item.get("map_name")), + }, + "result": { + "allied_score": None, + "axis_score": None, + "winner": None, + }, + "player_count": int(round(float(item.get("average_players") or 0))), + "peak_players": item.get("peak_players"), + "sample_count": item.get("sample_count"), + "capture_basis": "rcon-competitive-window", + "capabilities": item.get("capabilities"), + "match_url": None, + } + + def _build_server_summary(item: dict[str, object]) -> dict[str, object]: sample_count = int(item.get("sample_count") or 0) first_last_points = list_rcon_historical_recent_activity( diff --git a/backend/app/rcon_historical_storage.py b/backend/app/rcon_historical_storage.py index 7295b7f..3f59662 100644 --- a/backend/app/rcon_historical_storage.py +++ b/backend/app/rcon_historical_storage.py @@ -662,6 +662,79 @@ def find_rcon_historical_competitive_window( } +def get_rcon_historical_competitive_window_by_session( + *, + server_key: str, + session_key: str, + db_path: Path | None = None, +) -> dict[str, object] | None: + """Return one persisted competitive RCON window by its synthetic session key.""" + normalized_session_key = str(session_key or "").strip() + if not normalized_session_key: + return None + resolved_path = _resolve_db_path(db_path) + aliases = _expand_target_key_aliases(server_key) + alias_placeholders = ", ".join("?" for _ in aliases) + try: + with _connect_readonly(resolved_path) as connection: + row = connection.execute( + f""" + SELECT + targets.target_key, + targets.external_server_id, + targets.display_name, + targets.region, + windows.session_key, + windows.map_name, + windows.map_pretty_name, + windows.first_seen_at, + windows.last_seen_at, + windows.sample_count, + windows.total_players, + windows.peak_players, + windows.confidence_mode, + windows.capabilities_json + FROM rcon_historical_competitive_windows AS windows + INNER JOIN rcon_historical_targets AS targets + ON targets.id = windows.target_id + WHERE windows.session_key = ? + AND ( + targets.target_key IN ({alias_placeholders}) + OR targets.external_server_id IN ({alias_placeholders}) + ) + LIMIT 1 + """, + [normalized_session_key, *aliases, *aliases], + ).fetchone() + except sqlite3.OperationalError: + return None + if row is None: + return None + sample_count = int(row["sample_count"] or 0) + return { + "target_key": row["target_key"], + "external_server_id": row["external_server_id"], + "display_name": row["display_name"], + "region": row["region"], + "session_key": row["session_key"], + "first_seen_at": row["first_seen_at"], + "last_seen_at": row["last_seen_at"], + "duration_seconds": _calculate_duration_seconds( + row["first_seen_at"], + row["last_seen_at"], + ), + "map_name": row["map_name"], + "map_pretty_name": row["map_pretty_name"] or row["map_name"], + "sample_count": sample_count, + "average_players": round((int(row["total_players"] or 0) / sample_count), 2) + if sample_count > 0 + else 0.0, + "peak_players": int(row["peak_players"] or 0), + "confidence_mode": row["confidence_mode"], + "capabilities": _deserialize_json_object(row["capabilities_json"]), + } + + def _connect(db_path: Path) -> sqlite3.Connection: return connect_sqlite_writer(db_path) diff --git a/backend/app/routes.py b/backend/app/routes.py index 8c19f02..607ffac 100644 --- a/backend/app/routes.py +++ b/backend/app/routes.py @@ -13,6 +13,7 @@ from .payloads import ( build_error_payload, build_health_payload, build_historical_leaderboard_payload, + build_historical_match_detail_payload, build_monthly_mvp_payload, build_monthly_mvp_v2_payload, build_monthly_leaderboard_payload, @@ -254,6 +255,19 @@ def resolve_get_payload(path: str) -> tuple[HTTPStatus | None, dict[str, object] server_slug=server_slug, ) + if parsed.path == "/api/historical/matches/detail": + params = parse_qs(parsed.query) + server_slug = params.get("server", [None])[0] + match_id = params.get("match", [None])[0] + if not server_slug: + return HTTPStatus.BAD_REQUEST, build_error_payload("Server parameter is required") + if not match_id: + return HTTPStatus.BAD_REQUEST, build_error_payload("Match parameter is required") + return HTTPStatus.OK, build_historical_match_detail_payload( + server_slug=server_slug, + match_id=match_id, + ) + if parsed.path == "/api/historical/server-summary": server_slug = parse_qs(parsed.query).get("server", [None])[0] return HTTPStatus.OK, build_historical_server_summary_payload(server_slug=server_slug) diff --git a/frontend/assets/js/historico-partida.js b/frontend/assets/js/historico-partida.js new file mode 100644 index 0000000..8b9003b --- /dev/null +++ b/frontend/assets/js/historico-partida.js @@ -0,0 +1,252 @@ +document.addEventListener("DOMContentLoaded", () => { + const backendBaseUrl = + document.body.dataset.backendBaseUrl || "http://127.0.0.1:8000"; + const params = new URLSearchParams(window.location.search); + const serverSlug = params.get("server") || ""; + const matchId = params.get("match") || ""; + const titleNode = document.getElementById("match-detail-title"); + const summaryNode = document.getElementById("match-detail-summary"); + const noteNode = document.getElementById("match-detail-note"); + const stateNode = document.getElementById("match-detail-state"); + const gridNode = document.getElementById("match-detail-grid"); + const actionsNode = document.getElementById("match-detail-actions"); + + if (!serverSlug || !matchId) { + titleNode.textContent = "Partida no seleccionada"; + summaryNode.textContent = "Vuelve al historico y abre una partida registrada."; + noteNode.textContent = "Faltan parametros internos para cargar este detalle."; + setState(stateNode, "No hay una partida seleccionada.", true); + return; + } + + void loadMatchDetail({ + backendBaseUrl, + serverSlug, + matchId, + titleNode, + summaryNode, + noteNode, + stateNode, + gridNode, + actionsNode, + }); +}); + +async function loadMatchDetail({ + backendBaseUrl, + serverSlug, + matchId, + titleNode, + summaryNode, + noteNode, + stateNode, + gridNode, + actionsNode, +}) { + try { + const payload = await fetchJson( + `${backendBaseUrl}/api/historical/matches/detail?server=${encodeURIComponent( + serverSlug, + )}&match=${encodeURIComponent(matchId)}`, + ); + const data = payload?.data; + const item = data?.item; + if (!data?.found || !item) { + titleNode.textContent = "Detalle no disponible"; + summaryNode.textContent = + "La partida existe como enlace interno, pero todavia no hay detalle suficiente para mostrar."; + noteNode.textContent = + "El historico local puede tener solo una ventana RCON parcial o ningun registro ampliado."; + setState(stateNode, "Detalle no disponible para esta partida."); + return; + } + + renderMatchDetail(item, { + titleNode, + summaryNode, + noteNode, + stateNode, + gridNode, + actionsNode, + }); + } catch (error) { + titleNode.textContent = "Detalle no disponible"; + summaryNode.textContent = "No se pudo conectar con el backend local."; + noteNode.textContent = "Comprueba que el backend este levantado y vuelve a intentarlo."; + setState(stateNode, "Error al cargar el detalle de la partida.", true); + } +} + +function renderMatchDetail( + item, + { titleNode, summaryNode, noteNode, stateNode, gridNode, actionsNode }, +) { + const mapName = item.map?.pretty_name || item.map?.name || "Mapa no disponible"; + const serverName = item.server?.name || "Servidor no disponible"; + titleNode.textContent = mapName; + summaryNode.textContent = `${serverName} | Partida ${item.match_id || "sin id"}`; + noteNode.textContent = buildDetailNote(item); + gridNode.innerHTML = [ + renderDetailCard("Servidor", serverName), + renderDetailCard("Inicio", formatTimestamp(item.started_at)), + renderDetailCard("Cierre", formatTimestamp(item.closed_at || item.ended_at)), + renderDetailCard("Duracion", formatDuration(item.duration_seconds)), + renderDetailCard("Jugadores", formatPlayerCount(item)), + renderDetailCard("Marcador", formatScore(item.result)), + renderDetailCard("Resultado", formatMatchResult(item.result)), + renderDetailCard("Base de captura", formatCaptureBasis(item.capture_basis)), + ].join(""); + renderActions(item, actionsNode); + stateNode.hidden = true; + gridNode.hidden = false; +} + +function renderActions(item, actionsNode) { + const matchUrl = normalizeExternalMatchUrl(item.match_url); + if (!matchUrl) { + actionsNode.innerHTML = ""; + actionsNode.hidden = true; + return; + } + actionsNode.innerHTML = ` + + Abrir en scoreboard + + `; + actionsNode.hidden = false; +} + +function renderDetailCard(label, value) { + return ` +
+

${escapeHtml(label)}

+ ${escapeHtml(value)} +
+ `; +} + +function buildDetailNote(item) { + if (item.capture_basis === "rcon-competitive-window") { + return "Detalle interno generado desde ventanas competitivas RCON. Algunos datos pueden estar limitados."; + } + return "Detalle servido desde el historico local disponible para esta partida."; +} + +function formatCaptureBasis(value) { + if (value === "rcon-competitive-window") { + return "Ventana competitiva RCON"; + } + if (value === "public-scoreboard-match") { + return "Scoreboard persistido"; + } + return "Historico local"; +} + +function formatPlayerCount(item) { + if (Number.isFinite(Number(item.peak_players))) { + return `${formatNumber(item.player_count)} media / ${formatNumber(item.peak_players)} pico`; + } + return formatNumber(item.player_count); +} + +function formatDuration(value) { + const seconds = Number(value); + if (!Number.isFinite(seconds) || seconds <= 0) { + return "Duracion no disponible"; + } + const minutes = Math.round(seconds / 60); + if (minutes < 60) { + return `${formatNumber(minutes)} min`; + } + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + return `${formatNumber(hours)} h ${formatNumber(remainingMinutes)} min`; +} + +function formatMatchResult(result) { + const winner = result?.winner; + if (winner === "allies") { + return "Victoria Aliada"; + } + if (winner === "axis") { + return "Victoria Axis"; + } + if (winner === "draw") { + return "Empate"; + } + return "Resultado parcial"; +} + +function formatScore(result) { + const alliedScore = Number.isFinite(result?.allied_score) + ? result.allied_score + : "-"; + const axisScore = Number.isFinite(result?.axis_score) ? result.axis_score : "-"; + return `${alliedScore} - ${axisScore}`; +} + +function formatNumber(value) { + const parsedValue = Number(value); + if (!Number.isFinite(parsedValue)) { + return "0"; + } + + return new Intl.NumberFormat("es-ES").format(parsedValue); +} + +function formatTimestamp(timestamp) { + if (!timestamp) { + return "Fecha no disponible"; + } + + const value = new Date(timestamp); + if (Number.isNaN(value.getTime())) { + return "Fecha no disponible"; + } + + return new Intl.DateTimeFormat("es-ES", { + dateStyle: "short", + timeStyle: "short", + }).format(value); +} + +function normalizeExternalMatchUrl(value) { + if (typeof value !== "string" || !value.trim()) { + return ""; + } + try { + const url = new URL(value.trim()); + return ["http:", "https:"].includes(url.protocol) ? url.href : ""; + } catch (error) { + return ""; + } +} + +async function fetchJson(url) { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Request failed with ${response.status}`); + } + + return response.json(); +} + +function setState(node, message, isError = false) { + node.textContent = message; + node.hidden = false; + node.classList.toggle("is-error", isError); +} + +function escapeHtml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} diff --git a/frontend/assets/js/historico.js b/frontend/assets/js/historico.js index dc4f528..6fa9bf4 100644 --- a/frontend/assets/js/historico.js +++ b/frontend/assets/js/historico.js @@ -803,6 +803,7 @@ function hydrateMvpComparison( function renderRecentMatchCard(item) { const mapName = item.map?.pretty_name || item.map?.name || "Mapa no disponible"; const matchUrl = normalizeExternalMatchUrl(item.match_url || item.source_url); + const detailUrl = buildInternalMatchDetailUrl(item); const matchLink = matchUrl ? ` ` - : ""; + : detailUrl + ? ` + + Ver detalles + + ` + : ""; return `
@@ -861,6 +871,24 @@ function normalizeExternalMatchUrl(value) { } } +function buildInternalMatchDetailUrl(item) { + const serverSlug = item?.server?.slug; + const matchId = item?.match_id; + if (typeof serverSlug !== "string" || !serverSlug.trim()) { + return ""; + } + if (typeof matchId !== "string" && typeof matchId !== "number") { + return ""; + } + const normalizedMatchId = String(matchId).trim(); + if (!normalizedMatchId) { + return ""; + } + return `./historico-partida.html?server=${encodeURIComponent( + serverSlug.trim(), + )}&match=${encodeURIComponent(normalizedMatchId)}`; +} + function renderSummaryLoading(summaryNode) { summaryNode.innerHTML = renderSummaryCard("Estado", "Cargando datos historicos"); } diff --git a/frontend/historico-partida.html b/frontend/historico-partida.html new file mode 100644 index 0000000..b6905a4 --- /dev/null +++ b/frontend/historico-partida.html @@ -0,0 +1,74 @@ + + + + + + + Detalle de partida - HLL Vietnam + + + + +
+
+
+
+
+ + VOLVER HISTORICO + +

Detalle interno

+
+
+
+ Logo oficial de la comunidad HLL Vietnam +
+
+
+

+ Partida registrada +

+

+ Cargando datos disponibles del historico local. +

+
+
+
+
+
+ +
+
+
+
+
+

Partida historica

+

Datos disponibles

+

+ Leyendo el detalle interno de la partida. +

+
+
+

+ Cargando detalle... +

+ + +
+
+
+
+ + + +