feat: refine current match live feed

This commit is contained in:
devRaGonSa
2026-05-21 19:47:12 +02:00
parent 370e014b59
commit d558ac81ed
14 changed files with 330 additions and 152 deletions

View File

@@ -1,6 +1,6 @@
# TASK-156 - Current match realtime killfeed and home buttons layout
Status: pending
Status: done
## Goal
@@ -178,7 +178,23 @@ The current-match kill feed behaves like a compact real-time combat overlay, and
## Outcome
Document the validation performed, notable decisions, and any follow-up task that should be created instead of expanding this task.
- Kept the existing `event_id`-based killfeed refresh path and changed only its
rendering surface: the capped 15-event panel now reads as a bounded combat
screen with three desktop columns, while smaller breakpoints remain bounded.
- Removed the home server-card public-scoreboard action from the hydrated markup
and left the remaining `Historico` / `Partida actual` actions aligned by the
existing status-column layout.
- Validated with:
- `node --check frontend/assets/js/partida-actual.js`
- `node --check frontend/assets/js/main.js`
- `git diff --check`
- `docker compose up -d --build frontend`
- live endpoint checks for `/api/current-match/kills` on Comunidad Hispana
`#01` and `#02`
- rendered Chrome headless screenshots of the home page, current-match `#01`
desktop view and current-match `#02` mobile empty-state view
- The in-app Browser automation runtime was not callable after tool discovery,
so rendered validation used local headless Chrome against the task URLs.
## Change Budget

View File

@@ -1,6 +1,6 @@
# TASK-157 - Current match player team colors
Status: pending
Status: done
## Goal
@@ -160,7 +160,35 @@ The current-match player/stat section uses team-aware colors consistent with the
## Outcome
Document the validation performed, notable decisions, and any follow-up task that should be created instead of expanding this task.
- RCA found no backend contract gap: a live
`/api/current-match/players?server=comunidad-hispana-01` response observed
before the validation rebuild already exposed AdminLog-derived `team` values
such as `Allies` and `Axis`; unknown values remain handled by the existing
frontend neutral path.
- Reused the historical detail player row and team badge CSS modifiers from
`historico-scoreboard-detail.css` in the current-match player table instead
of adding a parallel style system.
- Included `team` in the visible player-table signature so a future team update
can rerender an already-visible row without changing its stats.
- Validated with:
- `node --check frontend/assets/js/partida-actual.js`
- `git diff --check`
- `docker compose up -d --build frontend`
- live `/api/current-match/players` checks on Comunidad Hispana `#01` and
`#02`
- rendered Chrome headless current-match validation for the available player
empty state after the backend restart
- served-script check confirming the historical row and badge modifiers are
present in `frontend/assets/js/partida-actual.js`
- The final rendered validation window had no current player rows after the
backend rebuild, so known-team colors were validated from the observed live
payload contract plus the reused historical class mapping rather than from a
live colored-row screenshot.
- Repository-level follow-up validation ran
`powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1`.
It returned exit code `0`, but its output also printed a historical SQLite
`database disk image is malformed` traceback after the pass banner; that
historical storage issue is outside this frontend task scope.
## Change Budget

View File

@@ -1,7 +1,7 @@
---
id: TASK-158
title: Current match realtime transport
status: pending
status: done
type: backend
team: Backend Senior
supporting_teams: [Frontend Senior]
@@ -160,7 +160,31 @@ The current-match kill feed updates in near-real-time, without depending on the
## Outcome
Document the RCA, transport decision, validation performed, load/freshness limitations, and any follow-up task that should be created instead of expanding this task.
RCA:
- The current page used one `30s` interval in `frontend/assets/js/partida-actual.js` for current-match metadata, player stats and the kill feed together.
- The kill feed read path is already safe for higher-frequency reads because it queries normalized persisted `rcon_admin_log_events` rows through `/api/current-match/kills`; it does not query RCON from the browser or expose raw AdminLog lines.
- Current match metadata can still call live RCON through the existing payload path, and player stats aggregate the kill feed read model, so those surfaces remain on the slower refresh path.
Transport decision:
- Implemented incremental short polling for the kill feed instead of SSE. The backend is still the standard-library JSON route stack, while the existing persisted read model can cheaply support a cursor.
- `/api/current-match/kills` now accepts `since_event_id` plus the existing `server` and `limit` inputs. Trusted server validation remains in `routes.py`.
- The frontend polls kill-feed rows every `1500ms`, sends the latest event id cursor after the first load, deduplicates by `event_id`, caps the in-memory visible buffer and avoids overlapping kill-feed requests. Metadata and player stats keep the existing `30s` refresh cadence.
Validation performed:
- `python -m compileall backend/app`
- `node --check frontend/assets/js/partida-actual.js`
- `git diff --check`
- Ran a direct Python smoke check for `list_current_match_kill_feed(..., since_event_id=...)` with temporary AdminLog rows after the focused pytest command could not run.
- Attempted focused backend tests with `python -m pytest backend/tests/test_current_match_payload.py backend/tests/test_rcon_admin_log_storage.py`; this environment failed with `No module named pytest`.
- Reviewed `git diff --name-only`; task-owned code changes are limited to the allowed current-match backend/frontend files plus the focused storage test and this task transition. The worktree already contained frontend and prior task changes from `TASK-156`/`TASK-157`.
Load and freshness limit:
- Browser polling now reads persisted AdminLog rows rather than issuing per-browser RCON calls.
- New kill visibility is still bounded by how quickly `app.rcon_historical_worker` or manual AdminLog ingestion persists fresh AdminLog rows. The current documented worker example is a `120s` loop, so operations may need a follow-up cadence decision if that persisted source is not fresh enough for the desired live screen.
## Change Budget

View File

@@ -1,7 +1,7 @@
---
id: TASK-159
title: Current match feed rollback and weapon icons
status: pending
status: done
type: frontend
team: Frontend Senior
supporting_teams: [Disenador grafico, Experto en interfaz]
@@ -162,7 +162,24 @@ The current-match feed returns to the previous preferred live-feed visual direct
## Outcome
Document the visual RCA, icon discovery/mapping decision, validation performed, and any follow-up task that should be created instead of expanding this task.
Visual RCA:
- Git history before the compact feed change showed a vertical `historical-match-card` list with one readable event per row/card. The rejected version introduced a fixed multi-column killfeed surface that reads like a grid/table.
- The feed was refactored back to a vertical card-like list while keeping the current live buffer, deduplication and no-flicker signature guard.
Weapon icon decision:
- Reused local assets already present under `frontend/assets/img/weapons/`.
- `partida-actual.js` maps normalized AdminLog weapon names for `M1 GARAND`, `MP40`, `M1A1 THOMPSON`, `GEWEHR 43`, `MG42` and aliases to local PNGs.
- Unknown/unmapped weapons keep their label for tooltip/accessibility and render a compact local fallback marker instead of hotlinking assets.
Validation performed:
- `node --check frontend/assets/js/partida-actual.js`
- `git diff --check`
- `docker compose build frontend`
- Reviewed the history diff for the prior accepted feed direction and inspected local weapon asset filenames.
- Rendered Browser verification was attempted through the required Browser workflow but blocked because the Browser JavaScript execution tool was not exposed in this session after tool discovery.
## Change Budget

View File

@@ -1,7 +1,7 @@
---
id: TASK-160
title: Home server card bottom actions
status: pending
status: done
type: frontend
team: Frontend Senior
supporting_teams: [Experto en interfaz]
@@ -143,7 +143,19 @@ The home server cards have a clean structural layout with only Historico and Par
## Outcome
Document the structural rendering cleanup, validation performed, and any follow-up task that should be created instead of expanding this task.
Structural cleanup:
- Home server card actions now live in a dedicated bottom row beside the map quickfact rather than inside the status column.
- The rendered card action markup keeps only `Historico` and `Partida actual`; trusted URL mappings stay intact for those actions.
- Removed the inline server-card layout workaround from `frontend/index.html` and moved the bottom-row layout rules into `frontend/assets/css/styles.css`, with a stacked narrow layout.
Validation performed:
- `node --check frontend/assets/js/main.js`
- `git diff --check`
- `docker compose build frontend`
- Checked the existing local frontend and backend endpoints were serving on `127.0.0.1:8080` and `127.0.0.1:8000`.
- Rendered Browser verification was attempted through the required Browser workflow but blocked because the Browser JavaScript execution tool was not exposed in this session after tool discovery.
## Change Budget

View File

@@ -354,12 +354,17 @@ def build_current_match_kill_feed_payload(
*,
server_slug: str,
limit: int = 30,
since_event_id: str | None = None,
) -> dict[str, object]:
"""Return normalized AdminLog kill rows for one trusted current-match page."""
origin = get_trusted_public_scoreboard_origin(server_slug)
if origin is None:
raise ValueError("Unsupported current match server.")
feed = list_current_match_kill_feed(server_key=origin.slug, limit=limit)
feed = list_current_match_kill_feed(
server_key=origin.slug,
limit=limit,
since_event_id=since_event_id,
)
return {
"status": "ok",
"data": {

View File

@@ -337,11 +337,13 @@ def list_current_match_kill_feed(
*,
server_key: str,
limit: int = 30,
since_event_id: str | None = None,
db_path: Path | None = None,
now: datetime | None = None,
) -> dict[str, object]:
"""Return safe recent kill rows for one AdminLog server window."""
resolved_path = initialize_rcon_admin_log_storage(db_path=db_path)
since_row_id = _parse_current_match_event_row_id(since_event_id)
if use_postgres_rcon_storage(explicit_sqlite_path=db_path):
from .postgres_rcon_storage import connect_postgres_compat
@@ -377,10 +379,11 @@ def list_current_match_kill_feed(
FROM rcon_admin_log_events
WHERE (target_key = ? OR external_server_id = ?)
AND event_type = 'kill'
AND (? IS NULL OR id > ?)
ORDER BY server_time DESC, id DESC
LIMIT ?
""",
(server_key, server_key, limit),
(server_key, server_key, since_row_id, since_row_id, limit),
).fetchall()
scope = "recent-admin-log-window"
confidence = "partial"
@@ -393,10 +396,11 @@ def list_current_match_kill_feed(
WHERE (target_key = ? OR external_server_id = ?)
AND event_type = 'kill'
AND server_time >= ?
AND (? IS NULL OR id > ?)
ORDER BY server_time DESC, id DESC
LIMIT ?
""",
(server_key, server_key, open_start_time, limit),
(server_key, server_key, open_start_time, since_row_id, since_row_id, limit),
).fetchall()
scope = "open-admin-log-match-window"
confidence = "admin-log-boundary"
@@ -613,6 +617,17 @@ def _serialize_kill_feed_row(row: Mapping[str, object]) -> dict[str, object]:
}
def _parse_current_match_event_row_id(value: object) -> int | None:
prefix, separator, row_id = str(value or "").rpartition(":")
if separator != ":" or not prefix.startswith("rcon-admin-log:"):
return None
try:
parsed = int(row_id)
except ValueError:
return None
return parsed if parsed > 0 else None
def _safe_event_field(value: object) -> str | None:
normalized = str(value or "").strip()
return normalized or None

View File

@@ -76,7 +76,8 @@ def resolve_get_payload(path: str) -> tuple[HTTPStatus | None, dict[str, object]
limit = _parse_limit(parsed.query)
if limit is None:
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid limit parameter")
server_slug = parse_qs(parsed.query).get("server", [None])[0]
params = parse_qs(parsed.query)
server_slug = params.get("server", [None])[0]
if not server_slug:
return HTTPStatus.BAD_REQUEST, build_error_payload("Server parameter is required")
if get_trusted_public_scoreboard_origin(server_slug) is None:
@@ -84,6 +85,7 @@ def resolve_get_payload(path: str) -> tuple[HTTPStatus | None, dict[str, object]
return HTTPStatus.OK, build_current_match_kill_feed_payload(
server_slug=server_slug,
limit=limit,
since_event_id=params.get("since_event_id", [None])[0],
)
if parsed.path == "/api/current-match/players":

View File

@@ -368,3 +368,40 @@ def test_current_match_kill_feed_marks_fresh_recent_fallback_rows_partial(tmp_pa
assert feed["stale_events_filtered"] == 0
assert [item["killer_name"] for item in feed["items"]] == ["Fresh Killer"]
gc.collect()
def test_current_match_kill_feed_filters_rows_before_incremental_cursor(tmp_path):
db_path = tmp_path / "admin_log.sqlite3"
persist_rcon_admin_log_entries(
target=TARGET,
entries=[
{
"timestamp": "2026-05-21T10:00:00Z",
"message": "[1:00 min (100)] MATCH START Mortain Warfare",
},
{
"timestamp": "2026-05-21T10:01:00Z",
"message": (
"[2:00 min (120)] KILL: First Killer(Allies/steam-first) -> "
"First Victim(Axis/steam-first-victim) with M1 GARAND"
),
},
{
"timestamp": "2026-05-21T10:02:00Z",
"message": (
"[3:00 min (140)] KILL: Next Killer(Axis/steam-next) -> "
"Next Victim(Allies/steam-next-victim) with MP40"
),
},
],
db_path=db_path,
)
feed = list_current_match_kill_feed(
server_key="test-rcon-target",
db_path=db_path,
since_event_id="rcon-admin-log:test-rcon-target:2",
)
assert [item["killer_name"] for item in feed["items"]] == ["Next Killer"]
gc.collect()

View File

@@ -591,50 +591,41 @@
.current-match-killfeed {
display: grid;
grid-auto-flow: column;
grid-template-rows: repeat(5, minmax(0, 1fr));
grid-auto-columns: minmax(0, 1fr);
gap: 10px 12px;
min-height: 250px;
max-height: 320px;
padding: 16px;
gap: 10px;
max-height: 760px;
padding: 4px 0 0;
overflow: hidden;
border: 1px solid rgba(210, 182, 118, 0.26);
border-radius: 8px;
background:
linear-gradient(90deg, rgba(210, 182, 118, 0.08) 1px, transparent 1px),
linear-gradient(180deg, rgba(183, 201, 125, 0.06) 1px, transparent 1px),
linear-gradient(180deg, rgba(12, 18, 11, 0.96), rgba(7, 10, 7, 0.98));
background-size: 33.333% 100%, 100% 62px, auto;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.04),
0 18px 38px rgba(0, 0, 0, 0.22);
}
.current-match-killfeed__row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto;
grid-template-columns: minmax(0, 1fr) minmax(102px, 150px) minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
min-height: 38px;
padding: 7px 9px;
gap: 12px;
min-height: 56px;
padding: 12px 14px;
overflow: hidden;
border: 1px solid rgba(159, 168, 141, 0.16);
border: 1px solid rgba(159, 168, 141, 0.18);
border-left: 3px solid rgba(183, 201, 125, 0.34);
border-radius: 6px;
color: var(--text);
background: rgba(24, 31, 21, 0.86);
background:
linear-gradient(135deg, rgba(183, 201, 125, 0.08), transparent 44%),
rgba(14, 19, 12, 0.84);
animation: current-match-killfeed-enter 180ms ease-out;
}
.current-match-killfeed__row.is-teamkill {
border-color: rgba(210, 182, 118, 0.42);
background: rgba(73, 49, 27, 0.72);
border-left-color: rgba(210, 182, 118, 0.64);
background:
linear-gradient(135deg, rgba(210, 182, 118, 0.12), transparent 48%),
rgba(73, 49, 27, 0.42);
}
.current-match-killfeed__player {
min-width: 0;
overflow: hidden;
font-size: 0.84rem;
font-size: 0.94rem;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
@@ -649,20 +640,41 @@
}
.current-match-killfeed__weapon {
display: inline-flex;
display: grid;
grid-template-rows: 34px;
align-items: center;
justify-content: center;
gap: 5px;
min-width: 34px;
min-height: 24px;
padding: 0 6px;
min-width: 102px;
min-height: 40px;
padding: 2px 8px;
border: 1px solid rgba(183, 201, 125, 0.24);
border-radius: 4px;
color: var(--text);
background: rgba(7, 10, 7, 0.8);
font-size: 0.68rem;
}
.current-match-killfeed__weapon-icon {
display: block;
width: min(100%, 116px);
height: 32px;
object-fit: contain;
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.72));
}
.current-match-killfeed__weapon-fallback {
display: grid;
width: 32px;
height: 32px;
place-items: center;
border: 1px solid rgba(210, 182, 118, 0.3);
border-radius: 4px;
color: var(--accent-warm);
font-size: 1rem;
font-weight: 800;
line-height: 1;
}
.current-match-killfeed__weapon-fallback[hidden] {
display: none;
}
.current-match-killfeed__weapon em {
@@ -920,14 +932,11 @@
}
.current-match-killfeed {
grid-template-rows: repeat(8, minmax(0, 1fr));
min-height: 312px;
max-height: 380px;
background-size: 50% 100%, 100% 48px, auto;
max-height: none;
}
.current-match-killfeed__row {
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
grid-template-columns: minmax(0, 1fr) minmax(96px, 128px) minmax(0, 1fr);
}
.current-match-killfeed__teamkill {
@@ -937,12 +946,13 @@
}
@media (max-width: 480px) {
.current-match-killfeed {
grid-auto-flow: row;
grid-template-rows: none;
min-height: 0;
max-height: none;
background-size: 100% 48px, 100% 48px, auto;
.current-match-killfeed__row {
grid-template-columns: 1fr;
gap: 8px;
}
.current-match-killfeed__weapon {
justify-self: start;
}
}

View File

@@ -617,7 +617,7 @@ h2 {
}
.server-card__top--stats {
align-items: stretch;
align-items: flex-start;
gap: 18px;
}
@@ -669,9 +669,10 @@ h2 {
.server-card__actions {
margin: 0;
width: 100%;
display: flex;
flex-direction: column;
align-items: flex-end;
align-self: end;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
}
@@ -680,7 +681,7 @@ h2 {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
width: auto;
max-width: 100%;
min-height: 42px;
min-width: 136px;
@@ -742,6 +743,14 @@ h2 {
gap: 12px;
}
.server-card__bottom {
display: grid;
grid-template-columns: minmax(0, 280px) minmax(272px, 1fr);
align-items: end;
justify-content: space-between;
gap: 16px;
}
.server-card__quickfact {
min-width: 0;
padding: 12px 14px;
@@ -1019,13 +1028,17 @@ h2 {
}
.server-card__actions {
justify-content: start;
justify-content: flex-start;
}
.server-card__quickfacts {
grid-template-columns: 1fr;
}
.server-card__bottom {
grid-template-columns: 1fr;
}
.clan-card__brand {
grid-template-columns: 1fr;
justify-items: start;

View File

@@ -297,10 +297,12 @@ function renderServerStatsCard(server) {
<div class="server-card__status-column">
<span class="server-state ${stateClass}">${escapeHtml(statusLabel)}</span>
<p class="server-card__population">${escapeHtml(`${players} / ${maxPlayers}`)}</p>
</div>
</div>
<div class="server-card__bottom">
${quickFacts}
${actionMarkup}
</div>
</div>
${quickFacts}
</article>
`;
}
@@ -342,16 +344,8 @@ function renderServerAction(server) {
return `
<div class="server-card__actions">
<a
class="server-action-link"
href="${escapeHtml(actions.publicScoreboardUrl)}"
target="_blank"
rel="noreferrer"
>
Scoreboard publico
</a>
<a class="server-action-link" href="${escapeHtml(actions.historicalUrl)}">
Nuestro historico
Historico
</a>
<a class="server-action-link" href="${escapeHtml(actions.currentMatchUrl)}">
Partida actual

View File

@@ -1,4 +1,5 @@
const CURRENT_MATCH_POLL_INTERVAL_MS = 30 * 1000;
const CURRENT_MATCH_KILL_FEED_POLL_INTERVAL_MS = 1500;
const CURRENT_MATCH_SERVERS = Object.freeze({
"comunidad-hispana-01": "Comunidad Hispana #01",
"comunidad-hispana-02": "Comunidad Hispana #02",
@@ -9,10 +10,14 @@ const CURRENT_MATCH_SCOREBOARDS = Object.freeze({
});
const CURRENT_MATCH_KILL_FEED_LIMIT = 15;
const CURRENT_MATCH_WEAPONS = Object.freeze({
"m1 garand": { label: "M1 Garand", glyph: "M1" },
mp40: { label: "MP40", glyph: "MP" },
"m1a1 thompson": { label: "M1A1 Thompson", glyph: "TH" },
unknown: { label: "Arma desconocida", glyph: "?" },
"m1 garand": { label: "M1 Garand", icon: "m1_garand.PNG" },
mp40: { label: "MP40", icon: "mp40.PNG" },
"m1a1 thompson": { label: "M1A1 Thompson", icon: "thompson.PNG" },
thompson: { label: "M1A1 Thompson", icon: "thompson.PNG" },
"gewehr 43": { label: "Gewehr 43", icon: "gewehr.PNG" },
g43: { label: "Gewehr 43", icon: "gewehr.PNG" },
mg42: { label: "MG42", icon: "mg42.PNG" },
unknown: { label: "Arma desconocida", icon: "" },
});
document.addEventListener("DOMContentLoaded", () => {
@@ -43,27 +48,43 @@ document.addEventListener("DOMContentLoaded", () => {
nodes.history.href = `./historico.html?server=${encodeURIComponent(serverSlug)}`;
const killFeedState = initializeKillFeed(nodes);
const playerStatsState = initializePlayerStats(nodes);
let refreshInFlight = false;
const refresh = async () => {
if (refreshInFlight) {
let matchRefreshInFlight = false;
const refreshMatchPanels = async () => {
if (matchRefreshInFlight) {
return;
}
refreshInFlight = true;
matchRefreshInFlight = true;
try {
await Promise.allSettled([
loadCurrentMatch({ backendBaseUrl, serverSlug, nodes }),
loadKillFeed({ backendBaseUrl, serverSlug, nodes, killFeedState }),
loadPlayerStats({ backendBaseUrl, serverSlug, nodes, playerStatsState }),
]);
} finally {
refreshInFlight = false;
matchRefreshInFlight = false;
}
};
void refresh();
let killFeedRefreshInFlight = false;
const refreshKillFeed = async () => {
if (killFeedRefreshInFlight) {
return;
}
killFeedRefreshInFlight = true;
try {
await loadKillFeed({ backendBaseUrl, serverSlug, nodes, killFeedState });
} finally {
killFeedRefreshInFlight = false;
}
};
void refreshMatchPanels();
void refreshKillFeed();
window.setInterval(() => {
void refresh();
void refreshMatchPanels();
}, CURRENT_MATCH_POLL_INTERVAL_MS);
window.setInterval(() => {
void refreshKillFeed();
}, CURRENT_MATCH_KILL_FEED_POLL_INTERVAL_MS);
});
async function loadCurrentMatch({ backendBaseUrl, serverSlug, nodes }) {
@@ -80,8 +101,11 @@ async function loadCurrentMatch({ backendBaseUrl, serverSlug, nodes }) {
async function loadKillFeed({ backendBaseUrl, serverSlug, nodes, killFeedState }) {
try {
const cursor = killFeedState.latestEventId
? `&since_event_id=${encodeURIComponent(killFeedState.latestEventId)}`
: "";
const payload = await fetchJson(
`${backendBaseUrl}/api/current-match/kills?server=${encodeURIComponent(serverSlug)}&limit=${CURRENT_MATCH_KILL_FEED_LIMIT}`,
`${backendBaseUrl}/api/current-match/kills?server=${encodeURIComponent(serverSlug)}&limit=${CURRENT_MATCH_KILL_FEED_LIMIT}${cursor}`,
);
renderKillFeed(payload?.data || {}, nodes, killFeedState);
} catch (error) {
@@ -108,7 +132,7 @@ function renderCurrentMatch(data, nodes) {
nodes.title.textContent = mapName;
nodes.summary.textContent = serverName;
nodes.note.textContent = data.found
? "Lectura en vivo recibida. La pagina se actualiza cada 30 segundos."
? "Lectura en vivo recibida. El feed de bajas se actualiza en tiempo casi real."
: "Todavia no hay snapshot live disponible para este servidor.";
nodes.scoreboard.href = scoreboardUrl || "./index.html";
nodes.scoreboard.hidden = !scoreboardUrl;
@@ -150,6 +174,7 @@ function initializeKillFeed(nodes) {
nodes.feedList = document.getElementById("current-match-feed-list");
return {
byId: new Map(),
latestEventId: "",
visibleSignature: "",
};
}
@@ -178,6 +203,7 @@ function renderKillFeed(data, nodes, state) {
const incoming = Array.isArray(data.items) ? data.items : [];
if (data.scope === "no-current-match-events") {
state.byId.clear();
state.latestEventId = "";
}
incoming.forEach((event) => {
if (event?.event_id) {
@@ -187,6 +213,8 @@ function renderKillFeed(data, nodes, state) {
const events = [...state.byId.values()]
.sort(compareKillFeedEvents)
.slice(0, CURRENT_MATCH_KILL_FEED_LIMIT);
state.byId = new Map(events.map((event) => [event.event_id, event]));
state.latestEventId = events[0]?.event_id || state.latestEventId;
if (events.length === 0) {
nodes.feedList.innerHTML = "";
state.visibleSignature = "";
@@ -227,7 +255,7 @@ function renderKillFeedRow(event) {
title="${escapeHtml(weapon.label)}"
aria-label="${escapeHtml(weapon.label)}"
>
<span aria-hidden="true">${escapeHtml(weapon.glyph)}</span>
${renderKillFeedWeaponIcon(weapon)}
<em>${escapeHtml(weapon.label)}</em>
</span>
<span class="current-match-killfeed__player current-match-killfeed__player--victim">
@@ -242,10 +270,29 @@ function resolveKillFeedWeapon(value) {
const key = normalizeLookupText(value);
return CURRENT_MATCH_WEAPONS[key] || {
label: String(value || CURRENT_MATCH_WEAPONS.unknown.label),
glyph: CURRENT_MATCH_WEAPONS.unknown.glyph,
icon: CURRENT_MATCH_WEAPONS.unknown.icon,
};
}
function renderKillFeedWeaponIcon(weapon) {
if (!weapon.icon) {
return '<span class="current-match-killfeed__weapon-fallback" aria-hidden="true">?</span>';
}
return `
<img
class="current-match-killfeed__weapon-icon"
src="./assets/img/weapons/${escapeHtml(weapon.icon)}"
alt=""
width="88"
height="32"
loading="lazy"
decoding="async"
onerror="this.hidden = true; this.nextElementSibling.hidden = false;"
/>
<span class="current-match-killfeed__weapon-fallback" aria-hidden="true" hidden>?</span>
`;
}
function renderPlayerStats(data, nodes, state) {
const items = Array.isArray(data.items) ? sortPlayerStats(data.items) : [];
if (items.length === 0) {
@@ -262,6 +309,7 @@ function renderPlayerStats(data, nodes, state) {
.map((item) =>
[
item.player_name,
item.team,
item.kills,
item.deaths,
item.teamkills,
@@ -312,10 +360,15 @@ function renderPlayerStatsTable(items) {
}
function renderPlayerStatsRow(item) {
const team = getPlayerTeamDisplay(item.team);
return `
<tr>
<tr class="historical-player-row historical-player-row--${team.key}">
<td>${escapeHtml(item.player_name || "Jugador no disponible")}</td>
<td>${escapeHtml(item.team || "No disponible")}</td>
<td class="historical-player-team-cell">
<span class="historical-player-team-badge historical-player-team-badge--${team.key}">
${escapeHtml(team.label)}
</span>
</td>
<td>${escapeHtml(formatStatNumber(item.kills))}</td>
<td>${escapeHtml(formatStatNumber(item.deaths))}</td>
<td>${escapeHtml(formatStatNumber(item.teamkills))}</td>
@@ -325,6 +378,17 @@ function renderPlayerStatsRow(item) {
`;
}
function getPlayerTeamDisplay(value) {
const normalized = String(value || "").trim().toLowerCase();
if (normalized === "allies" || normalized === "allied" || normalized === "aliados") {
return { key: "allies", label: "Aliados" };
}
if (normalized === "axis" || normalized === "eje") {
return { key: "axis", label: "Eje" };
}
return { key: "unknown", label: "No disponible" };
}
function toStatNumber(value) {
return Number.isFinite(Number(value)) ? Number(value) : 0;
}

View File

@@ -9,65 +9,6 @@
/>
<title>Comunidad Hispana - HLL Vietnam</title>
<link rel="stylesheet" href="./assets/css/styles.css" />
<style>
.server-card--stats {
min-height: 385px;
}
.server-card__top--stats {
min-height: 255px;
}
.server-card__status-column {
min-width: min(190px, 44%);
align-self: stretch;
align-content: start;
grid-template-rows: auto auto 1fr;
}
.server-card__actions {
align-self: end;
margin-top: 0;
}
.server-card__actions .server-action-link:first-child {
display: none;
}
.server-card__actions .server-action-link:nth-child(2) {
font-size: 0;
}
.server-card__actions .server-action-link:nth-child(2)::before {
content: "Historico";
font-size: 0.76rem;
}
.server-card__quickfacts {
max-width: min(280px, 48%);
}
@media (max-width: 680px) {
.server-card--stats,
.server-card__top--stats {
min-height: 0;
}
.server-card__status-column {
min-width: 150px;
align-self: start;
grid-template-rows: none;
}
.server-card__actions {
margin-top: 18px;
}
.server-card__quickfacts {
max-width: none;
}
}
</style>
</head>
<body
data-backend-base-url="http://127.0.0.1:8000"