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:
212
ai/tasks/done/TASK-113-add-internal-match-detail-links.md
Normal file
212
ai/tasks/done/TASK-113-add-internal-match-detail-links.md
Normal 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.
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
252
frontend/assets/js/historico-partida.js
Normal file
252
frontend/assets/js/historico-partida.js
Normal 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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
@@ -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
|
||||
? `
|
||||
<a
|
||||
@@ -814,6 +815,15 @@ function renderRecentMatchCard(item) {
|
||||
Ver partida
|
||||
</a>
|
||||
`
|
||||
: detailUrl
|
||||
? `
|
||||
<a
|
||||
class="historical-match-card__link"
|
||||
href="${escapeHtml(detailUrl)}"
|
||||
>
|
||||
Ver detalles
|
||||
</a>
|
||||
`
|
||||
: "";
|
||||
return `
|
||||
<article class="historical-match-card">
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
74
frontend/historico-partida.html
Normal file
74
frontend/historico-partida.html
Normal 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>
|
||||
Reference in New Issue
Block a user