feat: render rcon materialized match details
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
id: TASK-127
|
id: TASK-127
|
||||||
title: Update recent match link actions
|
title: Update recent match link actions
|
||||||
status: pending
|
status: done
|
||||||
type: frontend
|
type: frontend
|
||||||
team: Frontend Senior
|
team: Frontend Senior
|
||||||
supporting_teams:
|
supporting_teams:
|
||||||
@@ -78,3 +78,19 @@ Recent match cards should always offer internal details when supported and only
|
|||||||
- Stage only intended files.
|
- Stage only intended files.
|
||||||
- Commit the completed implementation.
|
- Commit the completed implementation.
|
||||||
- Push the branch to origin.
|
- Push the branch to origin.
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
Updated recent match cards to consume materialized RCON recent-match fields. Cards now show available scores, a friendly result status/source, an internal `Ver detalles` link built from `internal_detail_match_id` or `match_id`, and an external scoreboard link only when `match_url` is present.
|
||||||
|
|
||||||
|
The rendering keeps paused Elo/MVP blocks out of the page, does not reintroduce Comunidad Hispana #03, and avoids visible "snapshot" wording. Missing-score RCON competitive-window rows render as in-progress instead of placeholder dashes.
|
||||||
|
|
||||||
|
## Validation Result
|
||||||
|
|
||||||
|
- Passed: `node --check frontend/assets/js/historico.js`
|
||||||
|
- Passed: `node --check frontend/assets/js/historico-recent-live.js`
|
||||||
|
- Passed: `docker compose up -d --build backend frontend`
|
||||||
|
- Browser-verified `http://localhost:8080/historico.html`.
|
||||||
|
- Confirmed recent cards show internal detail links and materialized RCON score `3 - 2`.
|
||||||
|
- Confirmed external scoreboard links are not rendered when `match_url` is absent.
|
||||||
|
- Confirmed no visible Elo/MVP/Comunidad Hispana #03/snapshot wording appears.
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
id: TASK-128
|
id: TASK-128
|
||||||
title: Build simplified internal match detail page
|
title: Build simplified internal match detail page
|
||||||
status: pending
|
status: done
|
||||||
type: frontend
|
type: frontend
|
||||||
team: Frontend Senior
|
team: Frontend Senior
|
||||||
supporting_teams:
|
supporting_teams:
|
||||||
@@ -77,3 +77,20 @@ When a safe public scoreboard link is unavailable, users should still be able to
|
|||||||
- Stage only intended files.
|
- Stage only intended files.
|
||||||
- Commit the completed implementation.
|
- Commit the completed implementation.
|
||||||
- Push the branch to origin.
|
- Push the branch to origin.
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
Reworked `frontend/historico-partida.html` and `frontend/assets/js/historico-partida.js` into a simplified internal scoreboard-style detail page backed by `/api/historical/matches/detail?server=...&match=...`.
|
||||||
|
|
||||||
|
The page correctly URL-encodes materialized RCON match ids containing colons, displays match summary cards, source/confidence, optional external scoreboard action, player stats with K/D, top weapons, most-killed/death-by summaries, and timeline event counts. It handles missing player/timeline data with controlled empty states and does not expose raw player IDs.
|
||||||
|
|
||||||
|
## Validation Result
|
||||||
|
|
||||||
|
- Passed: `node --check frontend/assets/js/historico-partida.js`
|
||||||
|
- Passed: `docker compose up -d --build backend frontend`
|
||||||
|
- Browser-verified detail navigation from `http://localhost:8080/historico.html`.
|
||||||
|
- Browser-verified known materialized match detail renders at `http://localhost:8080/historico-partida.html?server=comunidad-hispana-02&match=comunidad-hispana-02%3A1779178461%3A1779183861%3Acarentanwarfare`.
|
||||||
|
- Confirmed AntonioPruna renders with 1 kill, 0 deaths and `M1 GARAND`.
|
||||||
|
- Confirmed the victim row renders 1 death and `death_by` AntonioPruna.
|
||||||
|
- Confirmed timeline/event counts render.
|
||||||
|
- Confirmed no visible Elo/MVP/Comunidad Hispana #03/snapshot wording appears.
|
||||||
@@ -490,6 +490,10 @@
|
|||||||
border-bottom: 0;
|
border-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.historical-table--players {
|
||||||
|
min-width: 920px;
|
||||||
|
}
|
||||||
|
|
||||||
.historical-table__position {
|
.historical-table__position {
|
||||||
color: var(--accent-warm);
|
color: var(--accent-warm);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
|||||||
@@ -4,60 +4,36 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const serverSlug = params.get("server") || "";
|
const serverSlug = params.get("server") || "";
|
||||||
const matchId = params.get("match") || "";
|
const matchId = params.get("match") || "";
|
||||||
const titleNode = document.getElementById("match-detail-title");
|
const nodes = {
|
||||||
const summaryNode = document.getElementById("match-detail-summary");
|
title: document.getElementById("match-detail-title"),
|
||||||
const noteNode = document.getElementById("match-detail-note");
|
summary: document.getElementById("match-detail-summary"),
|
||||||
const stateNode = document.getElementById("match-detail-state");
|
note: document.getElementById("match-detail-note"),
|
||||||
const gridNode = document.getElementById("match-detail-grid");
|
state: document.getElementById("match-detail-state"),
|
||||||
const actionsNode = document.getElementById("match-detail-actions");
|
grid: document.getElementById("match-detail-grid"),
|
||||||
const playersSectionNode = document.getElementById("match-detail-players-section");
|
actions: document.getElementById("match-detail-actions"),
|
||||||
const playersNoteNode = document.getElementById("match-detail-players-note");
|
playersSection: document.getElementById("match-detail-players-section"),
|
||||||
const playersStateNode = document.getElementById("match-detail-players-state");
|
playersNote: document.getElementById("match-detail-players-note"),
|
||||||
const playersTableShellNode = document.getElementById("match-detail-players-table-shell");
|
playersState: document.getElementById("match-detail-players-state"),
|
||||||
const playersBodyNode = document.getElementById("match-detail-players-body");
|
playersTableShell: document.getElementById("match-detail-players-table-shell"),
|
||||||
|
playersBody: document.getElementById("match-detail-players-body"),
|
||||||
|
timelineSection: document.getElementById("match-detail-timeline-section"),
|
||||||
|
timelineNote: document.getElementById("match-detail-timeline-note"),
|
||||||
|
timelineState: document.getElementById("match-detail-timeline-state"),
|
||||||
|
timelineGrid: document.getElementById("match-detail-timeline-grid"),
|
||||||
|
};
|
||||||
|
|
||||||
if (!serverSlug || !matchId) {
|
if (!serverSlug || !matchId) {
|
||||||
titleNode.textContent = "Partida no seleccionada";
|
nodes.title.textContent = "Partida no seleccionada";
|
||||||
summaryNode.textContent = "Vuelve al historico y abre una partida registrada.";
|
nodes.summary.textContent = "Vuelve al historico y abre una partida registrada.";
|
||||||
noteNode.textContent = "Faltan parametros internos para cargar este detalle.";
|
nodes.note.textContent = "Faltan parametros internos para cargar este detalle.";
|
||||||
setState(stateNode, "No hay una partida seleccionada.", true);
|
setState(nodes.state, "No hay una partida seleccionada.", true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
void loadMatchDetail({
|
void loadMatchDetail({ backendBaseUrl, serverSlug, matchId, nodes });
|
||||||
backendBaseUrl,
|
|
||||||
serverSlug,
|
|
||||||
matchId,
|
|
||||||
titleNode,
|
|
||||||
summaryNode,
|
|
||||||
noteNode,
|
|
||||||
stateNode,
|
|
||||||
gridNode,
|
|
||||||
actionsNode,
|
|
||||||
playersSectionNode,
|
|
||||||
playersNoteNode,
|
|
||||||
playersStateNode,
|
|
||||||
playersTableShellNode,
|
|
||||||
playersBodyNode,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
async function loadMatchDetail({
|
async function loadMatchDetail({ backendBaseUrl, serverSlug, matchId, nodes }) {
|
||||||
backendBaseUrl,
|
|
||||||
serverSlug,
|
|
||||||
matchId,
|
|
||||||
titleNode,
|
|
||||||
summaryNode,
|
|
||||||
noteNode,
|
|
||||||
stateNode,
|
|
||||||
gridNode,
|
|
||||||
actionsNode,
|
|
||||||
playersSectionNode,
|
|
||||||
playersNoteNode,
|
|
||||||
playersStateNode,
|
|
||||||
playersTableShellNode,
|
|
||||||
playersBodyNode,
|
|
||||||
}) {
|
|
||||||
try {
|
try {
|
||||||
const payload = await fetchJson(
|
const payload = await fetchJson(
|
||||||
`${backendBaseUrl}/api/historical/matches/detail?server=${encodeURIComponent(
|
`${backendBaseUrl}/api/historical/matches/detail?server=${encodeURIComponent(
|
||||||
@@ -67,131 +43,122 @@ async function loadMatchDetail({
|
|||||||
const data = payload?.data;
|
const data = payload?.data;
|
||||||
const item = data?.item;
|
const item = data?.item;
|
||||||
if (!data?.found || !item) {
|
if (!data?.found || !item) {
|
||||||
titleNode.textContent = "Detalle no disponible";
|
nodes.title.textContent = "Detalle no disponible";
|
||||||
summaryNode.textContent =
|
nodes.summary.textContent =
|
||||||
"La partida existe como enlace interno, pero todavia no hay detalle suficiente para mostrar.";
|
"La partida existe como enlace interno, pero todavia no hay detalle suficiente para mostrar.";
|
||||||
noteNode.textContent =
|
nodes.note.textContent =
|
||||||
"El historico local puede tener solo una ventana RCON parcial o ningun registro ampliado.";
|
"El historico local puede tener solo una ventana RCON parcial o ningun registro ampliado.";
|
||||||
setState(stateNode, "Detalle no disponible para esta partida.");
|
setState(nodes.state, "Detalle no disponible para esta partida.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
renderMatchDetail(item, {
|
renderMatchDetail(item, nodes);
|
||||||
titleNode,
|
|
||||||
summaryNode,
|
|
||||||
noteNode,
|
|
||||||
stateNode,
|
|
||||||
gridNode,
|
|
||||||
actionsNode,
|
|
||||||
playersSectionNode,
|
|
||||||
playersNoteNode,
|
|
||||||
playersStateNode,
|
|
||||||
playersTableShellNode,
|
|
||||||
playersBodyNode,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
titleNode.textContent = "Detalle no disponible";
|
nodes.title.textContent = "Detalle no disponible";
|
||||||
summaryNode.textContent = "No se pudo conectar con el backend local.";
|
nodes.summary.textContent = "No se pudo conectar con el backend local.";
|
||||||
noteNode.textContent = "Comprueba que el backend este levantado y vuelve a intentarlo.";
|
nodes.note.textContent = "Comprueba que el backend este levantado y vuelve a intentarlo.";
|
||||||
setState(stateNode, "Error al cargar el detalle de la partida.", true);
|
setState(nodes.state, "Error al cargar el detalle de la partida.", true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderMatchDetail(
|
function renderMatchDetail(item, nodes) {
|
||||||
item,
|
|
||||||
{
|
|
||||||
titleNode,
|
|
||||||
summaryNode,
|
|
||||||
noteNode,
|
|
||||||
stateNode,
|
|
||||||
gridNode,
|
|
||||||
actionsNode,
|
|
||||||
playersSectionNode,
|
|
||||||
playersNoteNode,
|
|
||||||
playersStateNode,
|
|
||||||
playersTableShellNode,
|
|
||||||
playersBodyNode,
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
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 serverName = item.server?.name || "Servidor no disponible";
|
const serverName = item.server?.name || item.server?.slug || "Servidor no disponible";
|
||||||
titleNode.textContent = mapName;
|
nodes.title.textContent = mapName;
|
||||||
summaryNode.textContent = `${serverName} | Partida ${item.match_id || "sin id"}`;
|
nodes.summary.textContent = `${serverName} | ${item.match_id || "partida sin id"}`;
|
||||||
noteNode.textContent = buildDetailNote(item);
|
nodes.note.textContent = buildDetailNote(item);
|
||||||
gridNode.innerHTML = [
|
nodes.grid.innerHTML = [
|
||||||
renderDetailCard("Servidor", serverName),
|
renderDetailCard("Servidor", serverName),
|
||||||
renderDetailCard("Mapa", mapName),
|
renderDetailCard("Mapa", mapName),
|
||||||
|
renderDetailCard("Modo", formatGameMode(item.game_mode || item.gamestate?.game_mode)),
|
||||||
|
renderDetailCard("Marcador", formatScore(item.result)),
|
||||||
|
renderDetailCard("Ganador", formatWinner(item.winner || item.result?.winner)),
|
||||||
|
renderDetailCard("Resultado", formatMatchResult(item.result)),
|
||||||
renderDetailCard("Inicio", formatTimestamp(item.started_at)),
|
renderDetailCard("Inicio", formatTimestamp(item.started_at)),
|
||||||
renderDetailCard("Fin", formatTimestamp(item.closed_at || item.ended_at)),
|
renderDetailCard("Fin", formatTimestamp(item.closed_at || item.ended_at)),
|
||||||
renderDetailCard("Duracion", formatDuration(item.duration_seconds)),
|
renderDetailCard("Duracion", formatDuration(item.duration_seconds)),
|
||||||
renderDetailCard("Jugadores media", formatNumber(item.player_count)),
|
renderDetailCard("Confianza", formatConfidence(item.confidence)),
|
||||||
renderDetailCard("Pico jugadores", formatOptionalNumber(item.peak_players)),
|
renderDetailCard(
|
||||||
renderDetailCard("Muestras RCON", formatOptionalNumber(item.sample_count)),
|
"Fuente",
|
||||||
renderDetailCard("Marcador", formatScore(item.result)),
|
formatSourceBasis(item.source_basis || item.result_source) || "No disponible",
|
||||||
renderDetailCard("Resultado", formatMatchResult(item.result)),
|
),
|
||||||
renderDetailCard("Base de captura", formatCaptureBasis(item.capture_basis)),
|
renderDetailCard("Base", formatCaptureBasis(item.capture_basis)),
|
||||||
renderDetailCard("Capacidades", formatCapabilities(item.capabilities)),
|
|
||||||
].join("");
|
].join("");
|
||||||
renderPlayerSection(item, {
|
renderPlayerSection(item, nodes);
|
||||||
playersSectionNode,
|
renderTimelineSection(item, nodes);
|
||||||
playersNoteNode,
|
renderActions(item, nodes.actions);
|
||||||
playersStateNode,
|
nodes.state.hidden = true;
|
||||||
playersTableShellNode,
|
nodes.grid.hidden = false;
|
||||||
playersBodyNode,
|
|
||||||
});
|
|
||||||
renderActions(item, actionsNode);
|
|
||||||
stateNode.hidden = true;
|
|
||||||
gridNode.hidden = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPlayerSection(
|
function renderPlayerSection(item, nodes) {
|
||||||
item,
|
|
||||||
{
|
|
||||||
playersSectionNode,
|
|
||||||
playersNoteNode,
|
|
||||||
playersStateNode,
|
|
||||||
playersTableShellNode,
|
|
||||||
playersBodyNode,
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
const players = Array.isArray(item.players) ? item.players : [];
|
const players = Array.isArray(item.players) ? item.players : [];
|
||||||
playersSectionNode.hidden = false;
|
nodes.playersSection.hidden = false;
|
||||||
if (players.length === 0) {
|
if (players.length === 0) {
|
||||||
playersNoteNode.textContent =
|
nodes.playersNote.textContent =
|
||||||
"Esta partida no tiene estadisticas por jugador disponibles en el detalle interno.";
|
"Esta partida no tiene estadisticas por jugador disponibles en el detalle interno.";
|
||||||
setState(
|
setState(
|
||||||
playersStateNode,
|
nodes.playersState,
|
||||||
item.capture_basis === "rcon-competitive-window"
|
"No hay filas de jugador registradas para esta partida.",
|
||||||
? "Las ventanas RCON actuales no incluyen desglose por jugador."
|
|
||||||
: "No hay filas de jugador registradas para esta partida.",
|
|
||||||
);
|
);
|
||||||
playersTableShellNode.hidden = true;
|
nodes.playersTableShell.hidden = true;
|
||||||
playersBodyNode.innerHTML = "";
|
nodes.playersBody.innerHTML = "";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
playersNoteNode.textContent = `${formatNumber(players.length)} jugadores con estadisticas locales.`;
|
nodes.playersNote.textContent = `${formatNumber(players.length)} jugadores con estadisticas locales.`;
|
||||||
playersStateNode.hidden = true;
|
nodes.playersState.hidden = true;
|
||||||
playersBodyNode.innerHTML = players.map((player) => renderPlayerRow(player)).join("");
|
nodes.playersBody.innerHTML = players.map((player) => renderPlayerRow(player)).join("");
|
||||||
playersTableShellNode.hidden = false;
|
nodes.playersTableShell.hidden = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPlayerRow(player) {
|
function renderPlayerRow(player) {
|
||||||
return `
|
return `
|
||||||
<tr>
|
<tr>
|
||||||
<td>${escapeHtml(player.name || "Jugador no identificado")}</td>
|
<td>${escapeHtml(player.player_name || player.name || "Jugador no identificado")}</td>
|
||||||
<td>${escapeHtml(formatTeamSide(player.team_side))}</td>
|
<td>${escapeHtml(formatTeamSide(player.team || player.team_side))}</td>
|
||||||
<td>${escapeHtml(formatOptionalNumber(player.level))}</td>
|
|
||||||
<td>${escapeHtml(formatOptionalNumber(player.kills))}</td>
|
<td>${escapeHtml(formatOptionalNumber(player.kills))}</td>
|
||||||
<td>${escapeHtml(formatOptionalNumber(player.deaths))}</td>
|
<td>${escapeHtml(formatOptionalNumber(player.deaths))}</td>
|
||||||
<td>${escapeHtml(formatOptionalNumber(player.teamkills))}</td>
|
<td>${escapeHtml(formatOptionalNumber(player.teamkills))}</td>
|
||||||
<td>${escapeHtml(formatOptionalNumber(player.combat))}</td>
|
<td>${escapeHtml(formatKdRatio(player))}</td>
|
||||||
<td>${escapeHtml(formatOptionalNumber(player.support))}</td>
|
<td>${escapeHtml(formatNamedCounts(player.top_weapons))}</td>
|
||||||
<td>${escapeHtml(formatDuration(player.time_seconds))}</td>
|
<td>${escapeHtml(formatNamedCounts(player.most_killed))}</td>
|
||||||
|
<td>${escapeHtml(formatNamedCounts(player.death_by))}</td>
|
||||||
</tr>
|
</tr>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderTimelineSection(item, nodes) {
|
||||||
|
const eventCounts = Array.isArray(item.timeline?.event_counts)
|
||||||
|
? item.timeline.event_counts
|
||||||
|
: [];
|
||||||
|
nodes.timelineSection.hidden = false;
|
||||||
|
if (eventCounts.length === 0) {
|
||||||
|
nodes.timelineNote.textContent =
|
||||||
|
"No hay resumen de eventos disponible para esta partida.";
|
||||||
|
setState(nodes.timelineState, "Sin eventos agregados para mostrar.");
|
||||||
|
nodes.timelineGrid.hidden = true;
|
||||||
|
nodes.timelineGrid.innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes.timelineNote.textContent = `${formatNumber(eventCounts.length)} tipos de evento registrados.`;
|
||||||
|
nodes.timelineState.hidden = true;
|
||||||
|
nodes.timelineGrid.innerHTML = eventCounts.map((event) => renderTimelineCard(event)).join("");
|
||||||
|
nodes.timelineGrid.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTimelineCard(event) {
|
||||||
|
const label =
|
||||||
|
event.event_type ||
|
||||||
|
event.type ||
|
||||||
|
event.name ||
|
||||||
|
event.label ||
|
||||||
|
"Evento registrado";
|
||||||
|
const count = event.count ?? event.total ?? event.event_count ?? 0;
|
||||||
|
return renderDetailCard(formatEventType(label), formatNumber(count));
|
||||||
|
}
|
||||||
|
|
||||||
function renderActions(item, actionsNode) {
|
function renderActions(item, actionsNode) {
|
||||||
const matchUrl = normalizeExternalMatchUrl(item.match_url);
|
const matchUrl = normalizeExternalMatchUrl(item.match_url);
|
||||||
if (!matchUrl) {
|
if (!matchUrl) {
|
||||||
@@ -206,7 +173,7 @@ function renderActions(item, actionsNode) {
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
Abrir en scoreboard
|
Ver scoreboard
|
||||||
</a>
|
</a>
|
||||||
`;
|
`;
|
||||||
actionsNode.hidden = false;
|
actionsNode.hidden = false;
|
||||||
@@ -222,73 +189,71 @@ function renderDetailCard(label, value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildDetailNote(item) {
|
function buildDetailNote(item) {
|
||||||
if (item.capture_basis === "rcon-competitive-window") {
|
const source = formatSourceBasis(item.source_basis || item.result_source);
|
||||||
return "Detalle interno generado desde ventanas competitivas RCON. Algunos datos pueden estar limitados.";
|
if (source) {
|
||||||
|
return `Detalle interno servido desde ${source}. Los campos sin cobertura se muestran como no disponibles.`;
|
||||||
}
|
}
|
||||||
return "Detalle servido desde el historico local disponible para esta partida.";
|
return "Detalle servido desde el historico local disponible para esta partida.";
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatCaptureBasis(value) {
|
function formatCaptureBasis(value) {
|
||||||
|
if (value === "rcon-materialized-admin-log") {
|
||||||
|
return "Registro RCON materializado";
|
||||||
|
}
|
||||||
if (value === "rcon-competitive-window") {
|
if (value === "rcon-competitive-window") {
|
||||||
return "Ventana competitiva RCON";
|
return "Ventana competitiva RCON";
|
||||||
}
|
}
|
||||||
if (value === "public-scoreboard-match") {
|
if (value === "public-scoreboard-match") {
|
||||||
return "Scoreboard persistido";
|
return "Scoreboard persistido";
|
||||||
}
|
}
|
||||||
return "Historico local";
|
return value ? String(value).replaceAll("-", " ") : "Historico local";
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatPlayerCount(item) {
|
function formatSourceBasis(value) {
|
||||||
if (Number.isFinite(Number(item.peak_players))) {
|
if (value === "admin-log-match-ended") {
|
||||||
return `${formatNumber(item.player_count)} media / ${formatNumber(item.peak_players)} pico`;
|
return "cierre de partida RCON";
|
||||||
}
|
}
|
||||||
return formatNumber(item.player_count);
|
if (value === "rcon-session") {
|
||||||
}
|
return "sesion RCON";
|
||||||
|
|
||||||
function formatOptionalNumber(value) {
|
|
||||||
return value === null || value === undefined ? "No disponible" : formatNumber(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatCapabilities(capabilities) {
|
|
||||||
if (!capabilities || typeof capabilities !== "object") {
|
|
||||||
return "No disponibles";
|
|
||||||
}
|
}
|
||||||
const labels = Object.entries(capabilities)
|
if (value === "public-scoreboard-match") {
|
||||||
.filter(([, value]) => value !== null && value !== undefined)
|
return "scoreboard externo";
|
||||||
.map(([key, value]) => `${formatCapabilityKey(key)}: ${formatCapabilityValue(value)}`);
|
}
|
||||||
return labels.length > 0 ? labels.join(" | ") : "No disponibles";
|
return value ? String(value).replaceAll("-", " ") : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatCapabilityKey(key) {
|
function formatConfidence(value) {
|
||||||
return String(key).replaceAll("_", " ");
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatCapabilityValue(value) {
|
|
||||||
if (value === "exact") {
|
if (value === "exact") {
|
||||||
return "exacto";
|
return "Exacta";
|
||||||
}
|
}
|
||||||
if (value === "approximate") {
|
if (value === "approximate") {
|
||||||
return "aproximado";
|
return "Aproximada";
|
||||||
}
|
}
|
||||||
if (value === "partial") {
|
if (value === "partial") {
|
||||||
return "parcial";
|
return "Parcial";
|
||||||
}
|
}
|
||||||
if (value === "unavailable") {
|
return value ? String(value).replaceAll("-", " ") : "No disponible";
|
||||||
return "no disponible";
|
|
||||||
}
|
|
||||||
return String(value);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTeamSide(value) {
|
function formatTeamSide(value) {
|
||||||
if (value === "allies") {
|
const normalized = String(value || "").toLowerCase();
|
||||||
|
if (normalized === "allies" || normalized === "allied") {
|
||||||
return "Aliados";
|
return "Aliados";
|
||||||
}
|
}
|
||||||
if (value === "axis") {
|
if (normalized === "axis") {
|
||||||
return "Axis";
|
return "Axis";
|
||||||
}
|
}
|
||||||
return value || "No disponible";
|
return value || "No disponible";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatGameMode(value) {
|
||||||
|
if (!value) {
|
||||||
|
return "Modo no disponible";
|
||||||
|
}
|
||||||
|
const normalized = String(value).replaceAll("_", " ").replaceAll("-", " ");
|
||||||
|
return normalized.charAt(0).toUpperCase() + normalized.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
function formatDuration(value) {
|
function formatDuration(value) {
|
||||||
const seconds = Number(value);
|
const seconds = Number(value);
|
||||||
if (!Number.isFinite(seconds) || seconds <= 0) {
|
if (!Number.isFinite(seconds) || seconds <= 0) {
|
||||||
@@ -304,25 +269,76 @@ function formatDuration(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatMatchResult(result) {
|
function formatMatchResult(result) {
|
||||||
const winner = result?.winner;
|
if (hasMatchScore(result)) {
|
||||||
if (winner === "allies") {
|
return `${formatWinner(result.winner)} (${formatScore(result)})`;
|
||||||
return "Victoria Aliada";
|
|
||||||
}
|
}
|
||||||
if (winner === "axis") {
|
return result?.winner ? formatWinner(result.winner) : "Resultado no disponible";
|
||||||
return "Victoria Axis";
|
}
|
||||||
|
|
||||||
|
function formatWinner(value) {
|
||||||
|
const normalized = String(value || "").toLowerCase();
|
||||||
|
if (normalized === "allies" || normalized === "allied") {
|
||||||
|
return "Aliados";
|
||||||
}
|
}
|
||||||
if (winner === "draw") {
|
if (normalized === "axis") {
|
||||||
|
return "Axis";
|
||||||
|
}
|
||||||
|
if (normalized === "draw") {
|
||||||
return "Empate";
|
return "Empate";
|
||||||
}
|
}
|
||||||
return "Resultado parcial";
|
return "No disponible";
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatScore(result) {
|
function formatScore(result) {
|
||||||
const alliedScore = Number.isFinite(result?.allied_score)
|
if (!hasMatchScore(result)) {
|
||||||
? result.allied_score
|
return "Resultado no disponible";
|
||||||
: "-";
|
}
|
||||||
const axisScore = Number.isFinite(result?.axis_score) ? result.axis_score : "-";
|
return `${Number(result.allied_score)} - ${Number(result.axis_score)}`;
|
||||||
return `${alliedScore} - ${axisScore}`;
|
}
|
||||||
|
|
||||||
|
function hasMatchScore(result) {
|
||||||
|
return (
|
||||||
|
Number.isFinite(Number(result?.allied_score)) &&
|
||||||
|
Number.isFinite(Number(result?.axis_score))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatOptionalNumber(value) {
|
||||||
|
return value === null || value === undefined ? "No disponible" : formatNumber(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatKdRatio(player) {
|
||||||
|
if (Number.isFinite(Number(player.kd_ratio))) {
|
||||||
|
return formatDecimal(player.kd_ratio, 2);
|
||||||
|
}
|
||||||
|
const kills = Number(player.kills);
|
||||||
|
const deaths = Number(player.deaths);
|
||||||
|
if (!Number.isFinite(kills) || !Number.isFinite(deaths)) {
|
||||||
|
return "No disponible";
|
||||||
|
}
|
||||||
|
return deaths > 0 ? formatDecimal(kills / deaths, 2) : formatDecimal(kills, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNamedCounts(items) {
|
||||||
|
if (!Array.isArray(items) || items.length === 0) {
|
||||||
|
return "No disponible";
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((item) => {
|
||||||
|
const name = item.name || item.label || "Sin nombre";
|
||||||
|
const count = item.count ?? item.total ?? 0;
|
||||||
|
return `${name} (${formatNumber(count)})`;
|
||||||
|
})
|
||||||
|
.join(" / ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatEventType(value) {
|
||||||
|
const normalized = String(value || "").replaceAll("_", " ").replaceAll("-", " ");
|
||||||
|
if (!normalized) {
|
||||||
|
return "Evento";
|
||||||
|
}
|
||||||
|
return normalized.charAt(0).toUpperCase() + normalized.slice(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatNumber(value) {
|
function formatNumber(value) {
|
||||||
@@ -330,10 +346,20 @@ function formatNumber(value) {
|
|||||||
if (!Number.isFinite(parsedValue)) {
|
if (!Number.isFinite(parsedValue)) {
|
||||||
return "0";
|
return "0";
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Intl.NumberFormat("es-ES").format(parsedValue);
|
return new Intl.NumberFormat("es-ES").format(parsedValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDecimal(value, fractionDigits = 1) {
|
||||||
|
const parsedValue = Number(value);
|
||||||
|
if (!Number.isFinite(parsedValue)) {
|
||||||
|
return "0";
|
||||||
|
}
|
||||||
|
return new Intl.NumberFormat("es-ES", {
|
||||||
|
minimumFractionDigits: fractionDigits,
|
||||||
|
maximumFractionDigits: fractionDigits,
|
||||||
|
}).format(parsedValue);
|
||||||
|
}
|
||||||
|
|
||||||
function formatTimestamp(timestamp) {
|
function formatTimestamp(timestamp) {
|
||||||
if (!timestamp) {
|
if (!timestamp) {
|
||||||
return "Fecha no disponible";
|
return "Fecha no disponible";
|
||||||
@@ -367,7 +393,6 @@ async function fetchJson(url) {
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Request failed with ${response.status}`);
|
throw new Error(`Request failed with ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -804,18 +804,8 @@ 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);
|
const matchUrl = normalizeExternalMatchUrl(item.match_url);
|
||||||
const detailUrl = buildInternalMatchDetailUrl(item);
|
const detailUrl = buildInternalMatchDetailUrl(item);
|
||||||
const matchLink = matchUrl
|
const actionLinks = [
|
||||||
? `
|
detailUrl
|
||||||
<a
|
|
||||||
class="historical-match-card__link"
|
|
||||||
href="${escapeHtml(matchUrl)}"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
Ver partida
|
|
||||||
</a>
|
|
||||||
`
|
|
||||||
: detailUrl
|
|
||||||
? `
|
? `
|
||||||
<a
|
<a
|
||||||
class="historical-match-card__link"
|
class="historical-match-card__link"
|
||||||
@@ -824,7 +814,20 @@ function renderRecentMatchCard(item) {
|
|||||||
Ver detalles
|
Ver detalles
|
||||||
</a>
|
</a>
|
||||||
`
|
`
|
||||||
: "";
|
: "",
|
||||||
|
matchUrl
|
||||||
|
? `
|
||||||
|
<a
|
||||||
|
class="historical-match-card__link"
|
||||||
|
href="${escapeHtml(matchUrl)}"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
Ver scoreboard
|
||||||
|
</a>
|
||||||
|
`
|
||||||
|
: "",
|
||||||
|
].join("");
|
||||||
return `
|
return `
|
||||||
<article class="historical-match-card">
|
<article class="historical-match-card">
|
||||||
<div class="historical-match-card__top">
|
<div class="historical-match-card__top">
|
||||||
@@ -834,7 +837,7 @@ function renderRecentMatchCard(item) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="historical-match-card__actions">
|
<div class="historical-match-card__actions">
|
||||||
<span class="historical-match-card__result">${escapeHtml(formatMatchResult(item.result))}</span>
|
<span class="historical-match-card__result">${escapeHtml(formatMatchResult(item.result))}</span>
|
||||||
${matchLink}
|
${actionLinks}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="historical-match-meta">
|
<div class="historical-match-meta">
|
||||||
@@ -854,6 +857,10 @@ function renderRecentMatchCard(item) {
|
|||||||
<p class="historical-match-meta__label">Marcador</p>
|
<p class="historical-match-meta__label">Marcador</p>
|
||||||
<strong>${escapeHtml(formatScore(item.result))}</strong>
|
<strong>${escapeHtml(formatScore(item.result))}</strong>
|
||||||
</article>
|
</article>
|
||||||
|
<article>
|
||||||
|
<p class="historical-match-meta__label">Estado</p>
|
||||||
|
<strong>${escapeHtml(formatRecentMatchStatus(item))}</strong>
|
||||||
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
`;
|
`;
|
||||||
@@ -873,7 +880,7 @@ function normalizeExternalMatchUrl(value) {
|
|||||||
|
|
||||||
function buildInternalMatchDetailUrl(item) {
|
function buildInternalMatchDetailUrl(item) {
|
||||||
const serverSlug = item?.server?.slug;
|
const serverSlug = item?.server?.slug;
|
||||||
const matchId = item?.match_id;
|
const matchId = item?.internal_detail_match_id || item?.match_id;
|
||||||
if (typeof serverSlug !== "string" || !serverSlug.trim()) {
|
if (typeof serverSlug !== "string" || !serverSlug.trim()) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
@@ -1552,7 +1559,7 @@ function formatDateOnly(timestamp) {
|
|||||||
|
|
||||||
function formatMatchResult(result) {
|
function formatMatchResult(result) {
|
||||||
const winner = result?.winner;
|
const winner = result?.winner;
|
||||||
if (winner === "allies") {
|
if (winner === "allies" || winner === "allied") {
|
||||||
return "Victoria Aliada";
|
return "Victoria Aliada";
|
||||||
}
|
}
|
||||||
if (winner === "axis") {
|
if (winner === "axis") {
|
||||||
@@ -1565,15 +1572,54 @@ function formatMatchResult(result) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatScore(result) {
|
function formatScore(result) {
|
||||||
const alliedScore = Number.isFinite(result?.allied_score)
|
if (!hasMatchScore(result)) {
|
||||||
? result.allied_score
|
return "Resultado no disponible";
|
||||||
: "-";
|
}
|
||||||
const axisScore = Number.isFinite(result?.axis_score)
|
const alliedScore = Number(result.allied_score);
|
||||||
? result.axis_score
|
const axisScore = Number(result.axis_score);
|
||||||
: "-";
|
|
||||||
return `${alliedScore} - ${axisScore}`;
|
return `${alliedScore} - ${axisScore}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasMatchScore(result) {
|
||||||
|
return (
|
||||||
|
Number.isFinite(Number(result?.allied_score)) &&
|
||||||
|
Number.isFinite(Number(result?.axis_score))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRecentMatchStatus(item) {
|
||||||
|
if (hasMatchScore(item?.result)) {
|
||||||
|
const sourceLabel = formatResultSource(item?.result_source || item?.source_basis);
|
||||||
|
return sourceLabel ? `Resultado confirmado (${sourceLabel})` : "Resultado confirmado";
|
||||||
|
}
|
||||||
|
if (item?.capture_basis === "rcon-competitive-window") {
|
||||||
|
return "En curso";
|
||||||
|
}
|
||||||
|
if (item?.result_source || item?.source_basis || item?.capture_basis) {
|
||||||
|
return formatResultSource(item.result_source || item.source_basis || item.capture_basis);
|
||||||
|
}
|
||||||
|
return "Resultado no disponible";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatResultSource(value) {
|
||||||
|
if (value === "admin-log-match-ended") {
|
||||||
|
return "cierre RCON";
|
||||||
|
}
|
||||||
|
if (value === "rcon-session") {
|
||||||
|
return "sesion RCON";
|
||||||
|
}
|
||||||
|
if (value === "rcon-materialized-admin-log") {
|
||||||
|
return "registro RCON";
|
||||||
|
}
|
||||||
|
if (value === "public-scoreboard-match") {
|
||||||
|
return "scoreboard externo";
|
||||||
|
}
|
||||||
|
if (value === "rcon-competitive-window") {
|
||||||
|
return "ventana RCON";
|
||||||
|
}
|
||||||
|
return value ? String(value).replaceAll("-", " ") : "";
|
||||||
|
}
|
||||||
|
|
||||||
function formatNumber(value) {
|
function formatNumber(value) {
|
||||||
const parsedValue = Number(value);
|
const parsedValue = Number(value);
|
||||||
if (!Number.isFinite(parsedValue)) {
|
if (!Number.isFinite(parsedValue)) {
|
||||||
|
|||||||
@@ -80,19 +80,32 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th>Jugador</th>
|
<th>Jugador</th>
|
||||||
<th>Equipo</th>
|
<th>Equipo</th>
|
||||||
<th>Nivel</th>
|
|
||||||
<th>K</th>
|
<th>K</th>
|
||||||
<th>D</th>
|
<th>D</th>
|
||||||
<th>TK</th>
|
<th>TK</th>
|
||||||
<th>Combate</th>
|
<th>K/D</th>
|
||||||
<th>Apoyo</th>
|
<th>Armas</th>
|
||||||
<th>Tiempo</th>
|
<th>Mas abatido</th>
|
||||||
|
<th>Muere por</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="match-detail-players-body"></tbody>
|
<tbody id="match-detail-players-body"></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="historical-detail-section" id="match-detail-timeline-section" hidden>
|
||||||
|
<div class="historical-detail-section__header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow eyebrow--section">Eventos</p>
|
||||||
|
<h3>Linea de tiempo</h3>
|
||||||
|
</div>
|
||||||
|
<p class="historical-panel__note" id="match-detail-timeline-note">
|
||||||
|
Revisando eventos registrados para esta partida.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p class="historical-state" id="match-detail-timeline-state" hidden></p>
|
||||||
|
<div class="historical-summary-grid" id="match-detail-timeline-grid" hidden></div>
|
||||||
|
</div>
|
||||||
<div class="historical-match-card__actions" id="match-detail-actions" hidden></div>
|
<div class="historical-match-card__actions" id="match-detail-actions" hidden></div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
Reference in New Issue
Block a user