feat: add internal match detail links

Add internal match detail links for recent matches without safe external URLs while preserving external links when available.
This commit is contained in:
devRaGonSa
2026-05-19 10:37:28 +02:00
committed by GitHub
parent d2de0597b6
commit af4de29a0a
9 changed files with 854 additions and 1 deletions

View File

@@ -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.

View File

@@ -816,6 +816,83 @@ def list_recent_historical_matches(
return items 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( def list_historical_server_summaries(
*, *,
server_slug: str | None = None, server_slug: str | None = None,
@@ -3140,6 +3217,18 @@ def _resolve_safe_match_url(raw_payload_ref: object, scoreboard_base_url: object
return candidate 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: def _start_of_week(value: datetime) -> datetime:
normalized = value.astimezone(timezone.utc) normalized = value.astimezone(timezone.utc)
midnight = normalized.replace(hour=0, minute=0, second=0, microsecond=0) midnight = normalized.replace(hour=0, minute=0, second=0, microsecond=0)

View File

@@ -40,6 +40,7 @@ from .historical_snapshots import (
from .historical_storage import ( from .historical_storage import (
ALL_SERVERS_SLUG, ALL_SERVERS_SLUG,
DEFAULT_HISTORICAL_SERVERS, DEFAULT_HISTORICAL_SERVERS,
get_historical_match_detail,
get_historical_player_profile, get_historical_player_profile,
list_historical_server_summaries, list_historical_server_summaries,
list_monthly_leaderboard, list_monthly_leaderboard,
@@ -47,6 +48,7 @@ from .historical_storage import (
list_weekly_leaderboard, list_weekly_leaderboard,
list_weekly_top_kills, list_weekly_top_kills,
) )
from .rcon_historical_read_model import get_rcon_historical_match_detail
from .normalizers import normalize_map_name from .normalizers import normalize_map_name
from .storage import list_latest_snapshots, list_server_history, list_snapshot_history 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( def build_monthly_mvp_payload(
*, *,
limit: int = 10, limit: int = 10,

View File

@@ -8,6 +8,7 @@ from .historical_storage import ALL_SERVERS_SLUG
from .normalizers import normalize_map_name from .normalizers import normalize_map_name
from .rcon_historical_storage import ( from .rcon_historical_storage import (
find_rcon_historical_competitive_window, find_rcon_historical_competitive_window,
get_rcon_historical_competitive_window_by_session,
list_rcon_historical_competitive_summary_rows, list_rcon_historical_competitive_summary_rows,
list_rcon_historical_competitive_windows, 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]: def _build_server_summary(item: dict[str, object]) -> dict[str, object]:
sample_count = int(item.get("sample_count") or 0) sample_count = int(item.get("sample_count") or 0)
first_last_points = list_rcon_historical_recent_activity( first_last_points = list_rcon_historical_recent_activity(

View File

@@ -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: def _connect(db_path: Path) -> sqlite3.Connection:
return connect_sqlite_writer(db_path) return connect_sqlite_writer(db_path)

View File

@@ -13,6 +13,7 @@ from .payloads import (
build_error_payload, build_error_payload,
build_health_payload, build_health_payload,
build_historical_leaderboard_payload, build_historical_leaderboard_payload,
build_historical_match_detail_payload,
build_monthly_mvp_payload, build_monthly_mvp_payload,
build_monthly_mvp_v2_payload, build_monthly_mvp_v2_payload,
build_monthly_leaderboard_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, 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": if parsed.path == "/api/historical/server-summary":
server_slug = parse_qs(parsed.query).get("server", [None])[0] server_slug = parse_qs(parsed.query).get("server", [None])[0]
return HTTPStatus.OK, build_historical_server_summary_payload(server_slug=server_slug) return HTTPStatus.OK, build_historical_server_summary_payload(server_slug=server_slug)

View File

@@ -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 = `
<a
class="historical-match-card__link"
href="${escapeHtml(matchUrl)}"
target="_blank"
rel="noopener noreferrer"
>
Abrir en scoreboard
</a>
`;
actionsNode.hidden = false;
}
function renderDetailCard(label, value) {
return `
<article class="historical-stat-card">
<p>${escapeHtml(label)}</p>
<strong>${escapeHtml(value)}</strong>
</article>
`;
}
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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}

View File

@@ -803,6 +803,7 @@ function hydrateMvpComparison(
function renderRecentMatchCard(item) { function renderRecentMatchCard(item) {
const mapName = item.map?.pretty_name || item.map?.name || "Mapa no disponible"; const mapName = item.map?.pretty_name || item.map?.name || "Mapa no disponible";
const matchUrl = normalizeExternalMatchUrl(item.match_url || item.source_url); const matchUrl = normalizeExternalMatchUrl(item.match_url || item.source_url);
const detailUrl = buildInternalMatchDetailUrl(item);
const matchLink = matchUrl const matchLink = matchUrl
? ` ? `
<a <a
@@ -814,7 +815,16 @@ function renderRecentMatchCard(item) {
Ver partida Ver partida
</a> </a>
` `
: ""; : detailUrl
? `
<a
class="historical-match-card__link"
href="${escapeHtml(detailUrl)}"
>
Ver detalles
</a>
`
: "";
return ` return `
<article class="historical-match-card"> <article class="historical-match-card">
<div class="historical-match-card__top"> <div class="historical-match-card__top">
@@ -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) { function renderSummaryLoading(summaryNode) {
summaryNode.innerHTML = renderSummaryCard("Estado", "Cargando datos historicos"); summaryNode.innerHTML = renderSummaryCard("Estado", "Cargando datos historicos");
} }

View File

@@ -0,0 +1,74 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="Detalle interno de una partida historica registrada por HLL Vietnam."
/>
<title>Detalle de partida - HLL Vietnam</title>
<link rel="stylesheet" href="./assets/css/styles.css" />
<link rel="stylesheet" href="./assets/css/historico.css" />
</head>
<body data-backend-base-url="http://127.0.0.1:8000">
<div class="page-shell historical-shell">
<header class="hero historical-hero">
<div class="hero__overlay"></div>
<div class="hero__content historical-hero__content">
<div class="historical-hero__topline">
<a class="secondary-button secondary-button--ghost" href="./historico.html">
VOLVER HISTORICO
</a>
<p class="eyebrow">Detalle interno</p>
</div>
<div class="historical-hero__layout">
<div class="logo-frame historical-logo-frame">
<img
src="./assets/img/logo.png"
alt="Logo oficial de la comunidad HLL Vietnam"
class="logo-frame__image"
width="1024"
height="1044"
decoding="async"
/>
</div>
<div class="historical-hero__copy">
<div>
<h1 class="historical-hero__title" id="match-detail-title">
Partida registrada
</h1>
<p class="hero__text historical-hero__text" id="match-detail-summary">
Cargando datos disponibles del historico local.
</p>
</div>
</div>
</div>
</div>
</header>
<main class="content historical-content">
<section class="panel historical-panel" aria-labelledby="match-detail-panel-title">
<div class="panel__shell">
<div class="panel__header historical-panel__header">
<div>
<p class="eyebrow eyebrow--section">Partida historica</p>
<h2 id="match-detail-panel-title">Datos disponibles</h2>
<p class="historical-panel__note" id="match-detail-note">
Leyendo el detalle interno de la partida.
</p>
</div>
</div>
<p class="historical-state" id="match-detail-state" aria-live="polite">
Cargando detalle...
</p>
<div class="historical-summary-grid" id="match-detail-grid" hidden></div>
<div class="historical-match-card__actions" id="match-detail-actions" hidden></div>
</div>
</section>
</main>
</div>
<script src="./assets/js/historico-partida.js"></script>
</body>
</html>