const els = { statusBadge: document.querySelector('#statusBadge'), appVersion: document.querySelector('#appVersion'), serverFilter: document.querySelector('#serverFilter'), visibleCount: document.querySelector('#visibleCount'), totalCount: document.querySelector('#totalCount'), lastUpdated: document.querySelector('#lastUpdated'), nextRefresh: document.querySelector('#nextRefresh'), errorBox: document.querySelector('#errorBox'), rows: document.querySelector('#lobbyRows'), qFilter: document.querySelector('#qFilter'), gameFilter: document.querySelector('#gameFilter'), mapFilter: document.querySelector('#mapFilter'), regionFilter: document.querySelector('#regionFilter'), passFilter: document.querySelector('#passFilter'), sortBy: document.querySelector('#sortBy'), openOnly: document.querySelector('#openOnly'), resetBtn: document.querySelector('#resetBtn'), refreshBtn: document.querySelector('#refreshBtn'), }; let snapshot = null; let regionsReady = false; const dateTimeFormat = new Intl.DateTimeFormat('en-GB', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', }); function text(value) { return String(value || '').toLowerCase().trim(); } function formatDate(iso) { if (!iso) return '-'; const date = new Date(iso); if (Number.isNaN(date.getTime())) return '-'; return dateTimeFormat.format(date); } function formatCountdown(iso) { if (!iso) return snapshot?.refreshInProgress ? 'refreshing' : '-'; const ms = new Date(iso).getTime() - Date.now(); if (!Number.isFinite(ms) || ms <= 0) return snapshot?.refreshInProgress ? 'refreshing' : 'soon'; const seconds = Math.ceil(ms / 1000); const minutes = Math.floor(seconds / 60); const rest = String(seconds % 60).padStart(2, '0'); return `${minutes}:${rest}`; } function statusInfo(data) { if (!data) return ['starting', 'badge-muted']; if (data.refreshInProgress) return ['refreshing', 'badge-work']; if (data.connection?.steam === 'auth-required') return ['auth required', 'badge-warn']; if (data.connection?.steam === 'steam-guard') return ['steam guard', 'badge-warn']; if (data.status === 'ready') return ['online', 'badge-ok']; if (data.status === 'stale') return ['stale', 'badge-warn']; if (data.status === 'error') return ['error', 'badge-error']; if (data.connection?.steam !== 'online' || data.connection?.gc !== 'ready') return ['connecting', 'badge-work']; return [data.status || 'starting', 'badge-muted']; } function setBadge(label, klass) { els.statusBadge.textContent = label; els.statusBadge.className = `badge ${klass}`; } function initRegions(regions) { if (regionsReady || !regions) return; const current = els.regionFilter.value || 'all'; els.regionFilter.replaceChildren(); const all = document.createElement('option'); all.value = 'all'; all.textContent = 'all'; els.regionFilter.append(all); Object.entries(regions) .sort(([a], [b]) => Number(a) - Number(b)) .forEach(([id, name]) => { const option = document.createElement('option'); option.value = id; option.textContent = name; els.regionFilter.append(option); }); els.regionFilter.value = current; regionsReady = true; } function filterLobbies(lobbies) { const q = text(els.qFilter.value); const game = text(els.gameFilter.value); const map = text(els.mapFilter.value); const region = els.regionFilter.value; const pass = els.passFilter.value; const openOnly = els.openOnly.checked; return lobbies.filter((row) => { const haystack = text([ row.game, row.lobby, row.map, row.region, row.leader, row.customGameId, row.leaderAccountId, ].join(' ')); if (q && !haystack.includes(q)) return false; if (game && !text(row.game).includes(game)) return false; if (map && !text(row.map).includes(map)) return false; if (region !== 'all' && String(row.regionId) !== region) return false; if (pass === 'yes' && !row.hasPassKey) return false; if (pass === 'no' && row.hasPassKey) return false; if (openOnly && Number(row.openSlots) <= 0) return false; return true; }); } function sortLobbies(rows) { const sorted = [...rows]; const byName = (a, b, key) => String(a[key] || '').localeCompare(String(b[key] || ''), 'en'); sorted.sort((a, b) => { if (els.sortBy.value === 'open-desc') { return Number(b.openSlots) - Number(a.openSlots) || byName(a, b, 'game'); } if (els.sortBy.value === 'game-asc') { return byName(a, b, 'game') || Number(b.memberCount) - Number(a.memberCount); } if (els.sortBy.value === 'region-asc') { return byName(a, b, 'region') || byName(a, b, 'game'); } return Number(b.memberCount) - Number(a.memberCount) || byName(a, b, 'game'); }); return sorted; } function makeCell(value, className) { const td = document.createElement('td'); if (className) td.className = className; td.textContent = value || '-'; return td; } function renderRows(rows, totalLoaded) { els.rows.replaceChildren(); if (!rows.length) { const tr = document.createElement('tr'); const td = document.createElement('td'); td.colSpan = 7; td.className = 'empty'; td.textContent = totalLoaded ? 'No lobbies match the selected filters' : 'No data yet'; tr.append(td); els.rows.append(tr); return; } const fragment = document.createDocumentFragment(); for (const row of rows) { const tr = document.createElement('tr'); const game = document.createElement('td'); game.className = 'game'; const gameName = document.createElement('div'); gameName.textContent = row.game || row.customGameId || '-'; game.append(gameName); if (row.customGameId && row.game !== row.customGameId) { const id = document.createElement('div'); id.className = 'muted'; id.textContent = row.customGameId; game.append(id); } const players = document.createElement('td'); players.className = 'num'; const playersPill = document.createElement('span'); playersPill.className = 'pill'; playersPill.textContent = row.players || '-'; players.append(playersPill); const pass = document.createElement('td'); const passPill = document.createElement('span'); passPill.className = row.hasPassKey ? 'pill pass' : 'muted'; passPill.textContent = row.hasPassKey ? 'yes' : 'no'; pass.append(passPill); tr.append( game, makeCell(row.lobby), makeCell(row.map), players, makeCell(row.region), pass, makeCell(row.leader), ); fragment.append(tr); } els.rows.append(fragment); } function render() { const data = snapshot; const lobbies = data?.lobbies || []; const filtered = sortLobbies(filterLobbies(lobbies)); const [label, klass] = statusInfo(data); setBadge(label, klass); els.appVersion.textContent = data?.version ? `v${data.version}` : ''; els.visibleCount.textContent = String(filtered.length); els.totalCount.textContent = String(data?.totalLobbies || lobbies.length || 0); els.lastUpdated.textContent = formatDate(data?.lastUpdatedAt); els.nextRefresh.textContent = formatCountdown(data?.nextRefreshAt); els.refreshBtn.disabled = Boolean(data?.refreshInProgress); els.refreshBtn.textContent = data?.refreshInProgress ? 'Refreshing' : 'Refresh'; if (data?.serverFilters) { const parts = [`GC: ${data.serverFilters.regionName}`]; if (data.serverFilters.customGameId) parts.push(`game ${data.serverFilters.customGameId}`); els.serverFilter.textContent = parts.join(', '); } if (data?.lastError) { els.errorBox.hidden = false; els.errorBox.textContent = data.lastError; } else { els.errorBox.hidden = true; els.errorBox.textContent = ''; } renderRows(filtered, lobbies.length); } function setSnapshot(data) { snapshot = data; initRegions(data?.regions); render(); } async function fetchState() { const res = await fetch('/api/state', { cache: 'no-store' }); if (!res.ok) throw new Error(`HTTP ${res.status}`); setSnapshot(await res.json()); } async function refreshNow() { els.refreshBtn.disabled = true; try { const res = await fetch('/api/refresh', { method: 'POST', cache: 'no-store' }); if (!res.ok) throw new Error(`HTTP ${res.status}`); setSnapshot(await res.json()); } catch (err) { if (snapshot) { snapshot.lastError = err.message; render(); } else { els.errorBox.hidden = false; els.errorBox.textContent = err.message; } } finally { els.refreshBtn.disabled = Boolean(snapshot?.refreshInProgress); } } function connectEvents() { if (!window.EventSource) return; const events = new EventSource('/events'); events.onmessage = (event) => { try { setSnapshot(JSON.parse(event.data)); } catch (err) { console.error(err); } }; } [ els.qFilter, els.gameFilter, els.mapFilter, els.regionFilter, els.passFilter, els.sortBy, els.openOnly, ].forEach((el) => { el.addEventListener('input', render); el.addEventListener('change', render); }); els.resetBtn.addEventListener('click', () => { els.qFilter.value = ''; els.gameFilter.value = ''; els.mapFilter.value = ''; els.regionFilter.value = 'all'; els.passFilter.value = 'all'; els.sortBy.value = 'players-desc'; els.openOnly.checked = false; render(); }); els.refreshBtn.addEventListener('click', refreshNow); setInterval(() => { if (snapshot) els.nextRefresh.textContent = formatCountdown(snapshot.nextRefreshAt); }, 1000); setInterval(() => { fetchState().catch((err) => { if (!snapshot) { els.errorBox.hidden = false; els.errorBox.textContent = err.message; } }); }, 30000); connectEvents(); fetchState().catch((err) => { els.errorBox.hidden = false; els.errorBox.textContent = err.message; });