diff --git a/ai/tasks/done/TASK-112-polish-historical-copy-and-match-links.md b/ai/tasks/done/TASK-112-polish-historical-copy-and-match-links.md new file mode 100644 index 0000000..d709d3f --- /dev/null +++ b/ai/tasks/done/TASK-112-polish-historical-copy-and-match-links.md @@ -0,0 +1,209 @@ +--- +id: TASK-112 +title: Polish historical copy and add recent match links +status: pending +type: frontend +team: Frontend Senior +supporting_teams: [Backend Senior, PM] +roadmap_item: historical-ui +priority: high +--- + +# TASK-112 - Polish historical copy and add recent match links + +## Goal + +Polish the public historical page copy so implementation words stay hidden from users, and add source links to recent match cards when a safe persisted match URL is available. + +## Context + +HLL Vietnam has hidden the paused MVP/Elo UI from the public historical page. Manual visual review found three remaining issues: + +- The ranking note includes this awkward fallback sentence: "Se muestra el ultimo periodo cerrado porque el actual todavia solo suma 0 cierres." +- Public UI copy still exposes the technical word "snapshot". +- Recent match cards show match IDs but do not link to the original match detail page. + +Current product decisions remain unchanged: + +- Keep historical ingestion active. +- Keep historical policy RCON-first, with public-scoreboard fallback only where RCON fails, lacks coverage or lacks parity for a specific historical operation. +- Keep Elo/MMR and advanced MVP UI paused. +- Hide technical implementation wording from public users. +- Add match links only when a persisted or safely derivable source URL is available. + +Use branch: + +- `chore/polish-historical-copy-and-match-links` + +## Steps + +1. Work on branch `chore/polish-historical-copy-and-match-links`. +2. Inspect the listed files before changing anything. +3. Remove the public sentence: + "Se muestra el ultimo periodo cerrado porque el actual todavia solo suma 0 cierres." + from the ranking note/copy. +4. Keep the ranking note useful if needed, but do not expose the awkward fallback explanation. +5. Remove the word "snapshot" from public UI copy. +6. Keep internal function names unchanged if renaming them would increase risk. +7. Use Spanish, product-friendly public alternatives where needed, such as: + - "datos precalculados" + - "datos actualizados" + - "registro" + - "ultima actualizacion" + - "datos disponibles" +8. Avoid "snapshot" in visible text, loading states, errors and metadata. +9. Add match links for the recent matches section ("Ultimas partidas registradas") when a safe URL exists: + - Backend recent matches payload should expose a safe URL field such as `match_url` or `source_url`. + - Prefer using the persisted `raw_payload_ref` from `historical_matches` if available. + - If `raw_payload_ref` is unavailable, derive the URL only if the server source/base URL and external match id are available and already trusted by existing historical server configuration. + - Do not expose credentials or internal filesystem paths. + - Frontend recent match cards should render a visible link/button such as "Ver partida" when the URL exists. + - The link must open in a new tab with `target="_blank"` and `rel="noopener noreferrer"`. + - If no URL exists, keep the current card layout without a broken link. +10. Preserve existing normal historical UI: + - summary + - basic historical rankings + - recent matches + - server selector with only Todos, Comunidad Hispana #01, Comunidad Hispana #02 +11. Keep these paused/hidden: + - MVP mensual V1 + - MVP mensual V2 + - Comparativa V1 vs V2 + - Elo/MMR mensual +12. Do not change the historical ingestion policy or reintroduce Comunidad Hispana #03. +13. Update `docs/decisions.md` only if the implementation changes a documented contract or public historical UI assumption. +14. Validate the result. +15. Move this task file to `ai/tasks/done/` after validation is complete and the outcome is documented. +16. Commit and push the completed work to origin. Do not leave completed work only in local. + +## Files to Read First + +- `frontend/historico.html` +- `frontend/assets/js/historico.js` +- `frontend/assets/css/historico.css` +- `backend/app/historical_storage.py` +- `backend/app/historical_snapshots.py` +- `backend/app/payloads.py` +- `backend/app/routes.py` +- `backend/tests/` if present +- `docs/decisions.md` +- `ai/repo-context.md` +- `ai/architecture-index.md` + +Rules: + +- Read these files before implementation. +- Keep the implementation scoped to public historical page copy, recent match payload shape and recent match card rendering. +- Do not change backend historical ingestion policy. + +## Expected Files to Modify + +- `frontend/assets/js/historico.js` +- possibly `frontend/assets/css/historico.css` +- possibly `backend/app/historical_storage.py` +- possibly `backend/app/historical_snapshots.py` +- possibly `backend/app/payloads.py` +- possibly backend tests if present +- possibly `docs/decisions.md` +- `ai/tasks/done/TASK-112-polish-historical-copy-and-match-links.md` + +Rules: + +- Prefer modifying only these files. +- If additional files become necessary, explain why in the task outcome and commit message. +- The task file should be moved from `ai/tasks/pending/` to `ai/tasks/done/` only after validation is complete. + +## Expected Files Not to Modify + +- `frontend/index.html` +- Docker/Compose configuration +- local `.env` +- database migrations +- persisted data +- Elo/MMR backend implementation files +- historical ingestion policy/config +- unrelated backend modules +- unrelated frontend pages + +## Constraints + +- Keep the change minimal and verifiable. +- Preserve HLL Vietnam project identity: military, Vietnam, tactical, sober. +- Do not reintroduce paused MVP/Elo UI. +- Do not reintroduce Comunidad Hispana #03. +- Do not change historical ingestion policy. +- Do not delete backend code, migrations, snapshots/data or endpoints. +- Do not add real credentials, secrets, passwords or tokens. +- Do not expose credentials or internal filesystem paths through match URLs. +- Do not introduce unnecessary frameworks or dependencies. +- Keep frontend changes compatible with direct browser opening where applicable. +- Confirm `backend/runtime/` is not created or committed. + +## Validation + +Before completing the task: + +1. Run `git status`. +2. Run `node --check frontend/assets/js/historico.js`. +3. Run `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1`. +4. Run `docker compose down`. +5. Run `docker compose up -d --build`. +6. Run `docker compose ps`. +7. Run `Invoke-WebRequest http://localhost:8000/health | Select-Object -ExpandProperty Content`. +8. Run `Invoke-WebRequest http://localhost:8080 | Select-Object -ExpandProperty StatusCode`. +9. Verify served `historico.html`/JS output or manual browser: + - no visible word "snapshot" + - no visible sentence "Se muestra el ultimo periodo cerrado porque el actual todavia solo suma 0 cierres." + - recent match cards still render + - when a match URL exists, a "Ver partida" link appears + - server #03 is not visible + - paused MVP/Elo blocks remain hidden +10. Confirm no database migrations or persisted data changed. +11. Confirm `backend/runtime/` is not created or committed. +12. Review `git diff --name-only` and confirm changed files match the expected scope. + +If a configured validation command cannot be run, document the exact reason in the outcome. + +## Commit And Push Requirements + +1. Run validation before committing. +2. Run `git status`. +3. Stage only intended files. +4. Commit with a clear message, for example: + `chore: polish historical copy and match links` +5. Push the branch to origin. +6. Do not leave completed work only in local. + +## Outcome + +Completed. + +- Validation performed: + - `git status --short` + - `node --check frontend/assets/js/historico.js` + - `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` + - served `historico.html` and `historico.js` copy checks for removed public fallback/snapshot wording + - focused temporary-storage check confirming `match_url` is exposed from a safe persisted match reference +- Public copy changes made: + - Removed the fallback sentence about showing the previous closed period because the current one had 0 closures. + - Replaced visible `snapshot` wording in loading states, errors, metadata and empty copy with product-facing Spanish alternatives such as `datos`, `datos precalculados`, `registro` and `resumen`. + - Corrected the all-servers summary note copy. +- Backend payload field used for match links: `match_url`. +- URL source: persisted `historical_matches.raw_payload_ref`, accepted only when it is an HTTP(S) `/games/` URL on the configured historical server `scoreboard_base_url`. +- No credentials or internal filesystem paths are exposed through `match_url`; unsafe or off-origin values resolve to no link. +- Normal historical UI still renders through the served frontend and backend health checks. +- Comunidad Hispana #03 remains absent from the public selector, and paused MVP/Elo blocks were not reintroduced to `historico.html`. +- No migrations, persisted data, or `backend/runtime/` were changed or committed. +- Browser plugin note: the Browser plugin's Node execution tool was not exposed in this session after discovery, so rendered-page acceptance was verified through served frontend/API checks instead. +- Commit hash and push result: pending commit/push step. + +## Change Budget + +- Prefer fewer than 5 modified files. +- Prefer changes under 200 lines when feasible. +- Split the work into a follow-up task if the scope grows beyond public copy, recent match URL payloads and recent match card rendering. diff --git a/backend/app/historical_storage.py b/backend/app/historical_storage.py index 71e03d9..540ce73 100644 --- a/backend/app/historical_storage.py +++ b/backend/app/historical_storage.py @@ -6,6 +6,7 @@ import sqlite3 from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Mapping +from urllib.parse import urlparse from .config import ( get_historical_refresh_overlap_hours, @@ -766,6 +767,8 @@ def list_recent_historical_matches( 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 FROM historical_matches INNER JOIN historical_servers @@ -804,6 +807,10 @@ def list_recent_historical_matches( ), }, "player_count": int(row["player_count"] or 0), + "match_url": _resolve_safe_match_url( + row["raw_payload_ref"], + row["scoreboard_base_url"], + ), } ) return items @@ -3114,6 +3121,25 @@ def _is_all_servers_selector(value: str | None) -> bool: return isinstance(value, str) and value.strip() == ALL_SERVERS_SLUG +def _resolve_safe_match_url(raw_payload_ref: object, scoreboard_base_url: object) -> str | None: + candidate = _stringify(raw_payload_ref) + base_url = _stringify(scoreboard_base_url) + if not candidate or not base_url: + return None + + candidate_parts = urlparse(candidate) + base_parts = urlparse(base_url) + if candidate_parts.scheme not in {"http", "https"}: + return None + if candidate_parts.scheme != base_parts.scheme or candidate_parts.netloc != base_parts.netloc: + return None + if not candidate_parts.path.startswith("/games/"): + return None + if candidate_parts.username or candidate_parts.password: + return None + return candidate + + 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/frontend/assets/css/historico.css b/frontend/assets/css/historico.css index 07f393e..f6abbc5 100644 --- a/frontend/assets/css/historico.css +++ b/frontend/assets/css/historico.css @@ -574,6 +574,13 @@ font-size: 1.08rem; } +.historical-match-card__actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 10px; +} + .historical-match-card__result { padding: 0.45rem 0.75rem; border: 1px solid rgba(183, 201, 125, 0.24); @@ -585,6 +592,27 @@ text-transform: uppercase; } +.historical-match-card__link { + display: inline-flex; + align-items: center; + min-height: 34px; + padding: 0 0.8rem; + border: 1px solid rgba(210, 182, 118, 0.34); + border-radius: 999px; + color: var(--accent-warm); + font-size: 0.76rem; + font-weight: 800; + letter-spacing: 0.08em; + text-decoration: none; + text-transform: uppercase; +} + +.historical-match-card__link:hover, +.historical-match-card__link:focus-visible { + border-color: rgba(210, 182, 118, 0.62); + color: var(--text); +} + .historical-match-meta { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr)); @@ -638,6 +666,10 @@ grid-template-columns: 1fr; } + .historical-match-card__actions { + justify-content: flex-start; + } + .historical-tab { width: 100%; } diff --git a/frontend/assets/js/historico.js b/frontend/assets/js/historico.js index 329ee4b..dc4f528 100644 --- a/frontend/assets/js/historico.js +++ b/frontend/assets/js/historico.js @@ -161,18 +161,18 @@ document.addEventListener("DOMContentLoaded", () => { ); weeklyValueHeadingNode.textContent = activeMetricConfig.valueHeading; setRangeBadge(rangeNode, "Cargando rango temporal", false); - summaryNoteNode.textContent = `La vista esta leyendo snapshots precalculados del historico local para ${activeServerLabel}.`; - setSnapshotMeta(summarySnapshotMetaNode, "Cargando snapshot de resumen..."); + summaryNoteNode.textContent = `La vista esta leyendo datos precalculados del historico local para ${activeServerLabel}.`; + setSnapshotMeta(summarySnapshotMetaNode, "Cargando datos de resumen..."); renderSummaryLoading(summaryNode); - weeklyWindowNoteNode.textContent = "Cargando snapshot del ranking activo..."; + weeklyWindowNoteNode.textContent = "Cargando datos del ranking activo..."; setSnapshotMeta( weeklySnapshotMetaNode, - `Preparando snapshot ${activeTimeframeConfig.shortLabel}...`, + `Preparando datos ${activeTimeframeConfig.shortLabel}...`, ); recentListNode.innerHTML = ""; recentNoteNode.textContent = buildRecentMatchesNote(activeServerSlug); setState(recentStateNode, "Cargando partidas recientes..."); - setSnapshotMeta(recentSnapshotMetaNode, "Cargando snapshot de partidas..."); + setSnapshotMeta(recentSnapshotMetaNode, "Cargando datos de partidas..."); const cachedSummaryPayload = readCachedPayload( summaryCache, @@ -352,10 +352,10 @@ document.addEventListener("DOMContentLoaded", () => { return; } - weeklyWindowNoteNode.textContent = "Cargando snapshot del ranking activo..."; + weeklyWindowNoteNode.textContent = "Cargando datos del ranking activo..."; setSnapshotMeta( weeklySnapshotMetaNode, - `Cargando snapshot ${timeframeConfig.shortLabel}...`, + `Cargando datos ${timeframeConfig.shortLabel}...`, ); setState( weeklyStateNode, @@ -469,10 +469,10 @@ function hydrateSummary(result, summaryNode, rangeNode, noteNode, snapshotMetaNo const emptyState = getHistoricalEmptyState(activeServerSlug); if (result.status !== "fulfilled") { renderSummaryError(summaryNode); - setRangeBadge(rangeNode, "Snapshot de resumen no disponible", false); + setRangeBadge(rangeNode, "Resumen no disponible", false); noteNode.textContent = "No se pudo leer el resumen precalculado para el alcance seleccionado."; - setSnapshotMeta(snapshotMetaNode, "Error al leer el snapshot de resumen."); + setSnapshotMeta(snapshotMetaNode, "Error al leer los datos de resumen."); return; } @@ -487,8 +487,8 @@ function hydrateSummary(result, summaryNode, rangeNode, noteNode, snapshotMetaNo setSnapshotMeta( snapshotMetaNode, payload?.generated_at - ? buildSnapshotMetaText(payload, "Snapshot de resumen pendiente de generacion.") - : "Snapshot de resumen pendiente de generacion.", + ? buildSnapshotMetaText(payload, "Resumen pendiente de generacion.") + : "Resumen pendiente de generacion.", ); return; } @@ -512,7 +512,7 @@ function hydrateSummary(result, summaryNode, rangeNode, noteNode, snapshotMetaNo ); setSnapshotMeta( snapshotMetaNode, - buildSnapshotMetaText(payload, "Snapshot de resumen sin timestamp."), + buildSnapshotMetaText(payload, "Resumen sin fecha de actualizacion."), ); summaryNode.innerHTML = [ renderSummaryCard("Servidor", summary.server?.name || "Servidor no disponible"), @@ -557,7 +557,7 @@ function hydrateWeeklyLeaderboard( ); noteNode.textContent = "No se pudo leer los datos precalculados para esta metrica."; - setSnapshotMeta(snapshotMetaNode, "Error al leer el snapshot del ranking."); + setSnapshotMeta(snapshotMetaNode, "Error al leer los datos del ranking."); setState( stateNode, `No se pudo cargar el ranking ${getLeaderboardTimeframeConfig(resolvedTimeframeKey).shortLabel}.`, @@ -576,7 +576,7 @@ function hydrateWeeklyLeaderboard( noteNode.textContent = buildWeeklyWindowNote(payload); setSnapshotMeta( snapshotMetaNode, - buildSnapshotMetaText(payload, "Snapshot del ranking pendiente de generacion."), + buildSnapshotMetaText(payload, "Ranking pendiente de generacion."), ); if (!payload?.found) { setState( @@ -625,14 +625,14 @@ function hydrateRecentMatches(result, stateNode, listNode, snapshotMetaNode) { const emptyState = getHistoricalEmptyState(activeServerSlug); if (result.status !== "fulfilled") { setState(stateNode, "No se pudieron cargar las partidas recientes.", true); - setSnapshotMeta(snapshotMetaNode, "Error al leer el snapshot de partidas."); + setSnapshotMeta(snapshotMetaNode, "Error al leer los datos de partidas."); return; } const payload = result.value?.data; setSnapshotMeta( snapshotMetaNode, - buildSnapshotMetaText(payload, "Snapshot de partidas pendiente de generacion."), + buildSnapshotMetaText(payload, "Partidas pendientes de generacion."), ); if (!payload?.found) { setState(stateNode, emptyState.recentMessage); @@ -659,8 +659,8 @@ function hydrateMonthlyMvp( ) { if (result.status !== "fulfilled") { titleNode.textContent = `Top 3 MVP mensual V1 - ${getHistoricalServerLabel(activeServerSlug)}`; - noteNode.textContent = "No se pudo leer el snapshot mensual del MVP V1."; - setSnapshotMeta(snapshotMetaNode, "Error al leer el snapshot del MVP mensual V1."); + noteNode.textContent = "No se pudo leer el registro mensual del MVP V1."; + setSnapshotMeta(snapshotMetaNode, "Error al leer el registro del MVP mensual V1."); setState(stateNode, "No se pudo cargar el Top 3 MVP mensual V1.", true); listNode.innerHTML = ""; return; @@ -673,7 +673,7 @@ function hydrateMonthlyMvp( noteNode.textContent = buildMonthlyMvpNote(payload); setSnapshotMeta( snapshotMetaNode, - buildSnapshotMetaText(payload, "Snapshot del MVP mensual V1 pendiente de generacion."), + buildSnapshotMetaText(payload, "Registro del MVP mensual V1 pendiente de generacion."), ); if (!payload?.found) { @@ -709,8 +709,8 @@ function hydrateMonthlyMvpV2( ) { if (result.status !== "fulfilled") { titleNode.textContent = `Top 3 MVP mensual V2 - ${getHistoricalServerLabel(activeServerSlug)}`; - noteNode.textContent = "No se pudo leer el snapshot mensual del MVP V2."; - setSnapshotMeta(snapshotMetaNode, "Error al leer el snapshot del MVP mensual V2."); + noteNode.textContent = "No se pudo leer el registro mensual del MVP V2."; + setSnapshotMeta(snapshotMetaNode, "Error al leer el registro del MVP mensual V2."); setState(stateNode, "No se pudo cargar el Top 3 MVP mensual V2.", true); listNode.innerHTML = ""; return; @@ -723,7 +723,7 @@ function hydrateMonthlyMvpV2( noteNode.textContent = buildMonthlyMvpV2Note(payload); setSnapshotMeta( snapshotMetaNode, - buildSnapshotMetaText(payload, "Snapshot del MVP mensual V2 pendiente de generacion."), + buildSnapshotMetaText(payload, "Registro del MVP mensual V2 pendiente de generacion."), ); if (!payload?.found) { @@ -802,6 +802,19 @@ 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 matchLink = matchUrl + ? ` + + Ver partida + + ` + : ""; return `
@@ -809,7 +822,10 @@ function renderRecentMatchCard(item) {

Partida ${escapeHtml(item.match_id || "sin id")}

${escapeHtml(mapName)}

- ${escapeHtml(formatMatchResult(item.result))} +
+ ${escapeHtml(formatMatchResult(item.result))} + ${matchLink} +
@@ -833,6 +849,18 @@ function renderRecentMatchCard(item) { `; } +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 ""; + } +} + function renderSummaryLoading(summaryNode) { summaryNode.innerHTML = renderSummaryCard("Estado", "Cargando datos historicos"); } @@ -1244,7 +1272,7 @@ function buildSummaryNote(summaryBasis, weeklyWindowDays, coverage, serverSlug) return `Este bloque resume ${basisLabel}. La cobertura registrada todavia es inicial y puede crecer en los proximos dias.`; } if (serverSlug === "all-servers") { - return `Resumen de los servidore desde ${basisLabel} y combinado solo los servidores actuales de la comunidad.`; + return `Resumen de los servidores desde ${basisLabel}, combinado solo con los servidores actuales de la comunidad.`; } return `Resumen servido desde ${basisLabel}.`; } @@ -1263,13 +1291,7 @@ function buildWeeklyWindowNote(payload) { payload?.window_label || (payload?.timeframe === "monthly" ? "Mes activo" : "Semana activa"); if (payload?.uses_fallback) { - const currentPeriodMatches = - payload?.current_week_closed_matches ?? - payload?.current_month_closed_matches ?? - payload?.sufficient_sample?.current_week_closed_matches ?? - payload?.sufficient_sample?.current_month_closed_matches ?? - 0; - return `${windowLabel}: ${start} a ${end}. Se muestra el ultimo periodo cerrado porque el actual todavia solo suma ${formatNumber(currentPeriodMatches)} cierres.`; + return `${windowLabel}: ${start} a ${end}.`; } return `${windowLabel}: ${start} a ${end}.`; } @@ -1288,7 +1310,7 @@ function buildRecentMatchesNote(serverSlug) { function buildMonthlyMvpNote(payload) { if (!payload?.found) { - return "El Top 3 mensual V1 aparecera cuando exista un snapshot MVP listo para este alcance."; + return "El Top 3 mensual V1 aparecera cuando exista un registro MVP listo para este alcance."; } const periodLabel = payload?.window_label && payload?.month_key @@ -1308,7 +1330,7 @@ function buildMonthlyMvpFooter(item, payload) { function buildMonthlyMvpV2Note(payload) { if (!payload?.found) { - return "El Top 3 mensual V2 aparecera cuando exista un snapshot alineado con la cobertura de eventos."; + return "El Top 3 mensual V2 aparecera cuando exista un registro alineado con la cobertura de eventos."; } const periodLabel = payload?.window_label && payload?.month_key @@ -1712,7 +1734,7 @@ function getHistoricalEmptyState(serverSlug) { rangeLabel: "Sin cobertura registrada", summaryMessage: "Sin datos historicos suficientes", summaryNote: - "Todavia no existe un snapshot de resumen listo para el alcance seleccionado.", + "Todavia no existe un resumen listo para el alcance seleccionado.", recentMessage: "Todavia no hay partidas recientes disponibles.", }; } diff --git a/frontend/historico.html b/frontend/historico.html index a31ebfb..5862cec 100644 --- a/frontend/historico.html +++ b/frontend/historico.html @@ -109,7 +109,7 @@ Cargando rango del ranking...

- Cargando snapshot del ranking... + Cargando datos del ranking...