Files
gateway-gamers.net/src/main.js
T

498 lines
20 KiB
JavaScript
Raw Normal View History

/* ---- Shared: header / footer ---- */
function loadHTML(file, elementId, callback) {
fetch(file)
.then(r => r.text())
.then(html => {
document.getElementById(elementId).innerHTML = html;
if (callback) callback();
})
.catch(err => console.error('Error loading:', file, err));
}
function highlightActiveNav() {
const current = location.pathname.split('/').pop() || 'index.html';
document.querySelectorAll('#header a[href]').forEach(link => {
if (link.getAttribute('href') === `/${current}`) {
link.classList.add('active');
}
});
}
function startCarousel() {
const slides = document.querySelectorAll('#hero-carousel .carousel-slide');
if (!slides.length) return;
let current = 0;
slides[current].style.opacity = '1';
setInterval(() => {
slides[current].style.opacity = '0';
current = (current + 1) % slides.length;
slides[current].style.opacity = '1';
}, 5000);
}
loadHTML('/header.html', 'header', () => {
highlightActiveNav();
startCarousel();
});
loadHTML('/footer.html', 'footer');
/* ============================================================
CONFIG — the only block you edit
============================================================ */
const CONFIG = {
teamId: "2iGDVMgX0a",
leagueId: "BYaaUfKum7z0",
regionId: "VgQKgqmTPU",
lmcLeagueId: "cY0LeCAPfyiG",
// Fallback roster if the team endpoint requires auth.
memberIds: [],
// Flip to true to preview the layout with placeholder rows (no network).
useSample: false,
};
const API = "https://newprod-api.bestcoastpairings.com/v1";
const HEADERS = { "client-id": "web-app", "env": "bcp", "accept": "application/json" };
const CACHE_KEY = "gg_board_v3";
const $ = (id) => document.getElementById(id);
const fmtPts = (n) => Number(n || 0).toLocaleString(undefined, { minimumFractionDigits: 1, maximumFractionDigits: 1 });
const fmtWpct = (w, l, t) => { const g = (+w||0)+(+l||0)+(+t||0); return g ? ((+w||0)/g*100).toFixed(1)+'%' : '—'; };
const fullName = (u = {}) => [u.firstName, u.lastName].filter(Boolean).join(" ").trim();
/* ---- Career stats ---- */
const careerData = {}; // userId → { w, l, t } | null (in-flight)
async function fetchCareer(userId) {
const r = await fetch(
`${API}/placings?placingsType=player&userId=${userId}&limit=1000`,
{ headers: HEADERS, credentials: 'omit' }
);
if (!r.ok) return { w: 0, l: 0, t: 0, events: [] };
const p = await r.json();
const items = Array.isArray(p) ? p : (p.data || []);
let w = 0, l = 0, t = 0;
const events = [];
for (const item of items) {
const v = item.value || item;
if (v.wins != null || v.losses != null) {
w += +(v.wins || 0);
l += +(v.losses || 0);
t += +(v.ties || 0);
}
const eName = v.event?.name || v.eventName || v.tournament?.name || v.tournamentName || '';
if (eName) events.push(eName);
}
return { w, l, t, events };
}
async function loadCareerStats(kept) {
const toFetch = kept.filter(r => {
const uid = r.userId || r.user?.id;
return uid && !(uid in careerData);
});
if (!toFetch.length) return;
toFetch.forEach(r => { careerData[r.userId || r.user?.id] = null; });
await Promise.allSettled(toFetch.map(async r => {
const uid = r.userId || r.user?.id;
try { careerData[uid] = await fetchCareer(uid); }
catch { careerData[uid] = { w: 0, l: 0, t: 0, events: [] }; }
}));
if (lastState) render(lastState);
}
/* ---- ELO data (stat-check) ---- */
let eloData; // undefined = loading, null = unavailable, Map = loaded
function normalizeEventName(s) {
return String(s).toLowerCase().replace(/[^a-z0-9 ]/g, '').replace(/\s+/g, ' ').trim();
}
function eventNamesMatch(a, b) {
const na = normalizeEventName(a), nb = normalizeEventName(b);
if (!na || !nb || na.length < 6 || nb.length < 6) return na === nb;
return na === nb || na.includes(nb) || nb.includes(na);
}
async function loadEloData() {
try {
const r = await fetch('/elo-data.json');
if (!r.ok) { eloData = null; return; }
const d = await r.json();
const map = new Map();
for (const p of (d.players || [])) {
const key = String(p.name || '').toLowerCase();
if (!key) continue;
if (!map.has(key)) map.set(key, []);
map.get(key).push(p);
}
eloData = map;
if (lastState) render(lastState);
} catch { eloData = null; }
}
// Returns: undefined = still loading, null = no verified match, object = verified match
function findElo(name, uid) {
if (eloData === undefined) return undefined;
if (!eloData) return null;
const candidates = eloData.get(String(name).toLowerCase()) || [];
if (!candidates.length) return null;
if (uid && !(uid in careerData)) return undefined; // career fetch not started yet
const cd = uid ? careerData[uid] : undefined;
if (cd === null) return undefined; // career in-flight
const events = cd?.events || [];
if (!events.length) return candidates.length === 1 ? candidates[0] : null;
const verified = candidates.filter(c => events.some(e => eventNamesMatch(e, c.lastEvent)));
return verified.length === 1 ? verified[0] : null;
}
/* ---- Sort state ---- */
let sortState = { col: 'pts', dir: 'desc' };
let lastState = null;
const sortCols = {
pts: { key: r => +(r.ITCPoints || 0), defaultDir: 'desc' },
name: { key: r => fullName(r.user || {}).toLowerCase() || '', defaultDir: 'asc' },
wlt: { key: r => (+r.wins||0) * 10000 - (+r.losses||0) * 100 - (+r.ties||0), defaultDir: 'desc' },
wpct: { key: r => { const g=(+r.wins||0)+(+r.losses||0)+(+r.ties||0); return g?(+r.wins||0)/g:-1; }, defaultDir: 'desc' },
rank: { key: r => +(r.placing || Infinity), defaultDir: 'asc' },
lmc: { key: r => { const uid = r.userId || r.user?.id; return uid && lmcPoints[uid] != null ? lmcPoints[uid] : -Infinity; }, defaultDir: 'desc' },
lmcrank: { key: r => { const uid = r.userId || r.user?.id; return uid && lmcRanks[uid] != null ? lmcRanks[uid] : Infinity; }, defaultDir: 'asc' },
elo: { key: r => { const e = findElo(fullName(r.user || {}), r.userId || r.user?.id); return e?.elo ?? -Infinity; }, defaultDir: 'desc' },
};
function applySort(rows) {
const { col, dir } = sortState;
const sc = sortCols[col] || sortCols.pts;
return [...rows].sort((a, b) => {
const av = sc.key(a), bv = sc.key(b);
if (typeof av === 'string') return dir === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av);
return dir === 'asc' ? av - bv : bv - av;
});
}
function updateSortHeaders() {
document.querySelectorAll('th[data-sort]').forEach(th => {
const arrow = th.querySelector('.sort-arrow');
if (arrow) arrow.textContent = th.dataset.sort === sortState.col ? (sortState.dir === 'asc' ? ' ▲' : ' ▼') : '';
});
}
function initSortHeaders() {
document.querySelectorAll('th[data-sort]').forEach(th => {
const arrow = document.createElement('span');
arrow.className = 'sort-arrow text-xs opacity-60';
th.appendChild(arrow);
th.addEventListener('click', () => {
const col = th.dataset.sort;
if (sortState.col === col) {
sortState.dir = sortState.dir === 'asc' ? 'desc' : 'asc';
} else {
sortState.col = col;
sortState.dir = (sortCols[col] || sortCols.pts).defaultDir;
}
updateSortHeaders();
if (lastState) render(lastState);
});
});
updateSortHeaders();
}
/* ---- Roster ---- */
async function getMemberIds(force) {
try {
const r = await fetch(`${API}/teams/${CONFIG.teamId}?expand[]=owner`,
{ headers: HEADERS, credentials: "omit" });
if (!r.ok) throw new Error("auth/" + r.status);
let t = await r.json(); if (Array.isArray(t)) t = t[0] || {};
const ids = new Set();
if (Array.isArray(t.memberIds)) t.memberIds.forEach(x => typeof x === "string" && ids.add(x));
if (typeof t.ownerId === "string") ids.add(t.ownerId);
[t.users, t.members, t.teamMembers].forEach(list => {
if (Array.isArray(list)) list.forEach(o => o && o.id && ids.add(o.id));
});
if (t.owner && t.owner.id) ids.add(t.owner.id);
if (ids.size) return { ids, live: true };
throw new Error("empty");
} catch (e) {
if (CONFIG.memberIds.length) return { ids: new Set(CONFIG.memberIds), live: false };
throw e;
}
}
/* ---- LMC Points data ---- */
const lmcPoints = {}; // userId → number
const lmcRanks = {}; // userId → number
let lmcLoaded = false;
async function loadLmcData(ids) {
lmcLoaded = false;
try {
for await (const page of streamPlacings(CONFIG.lmcLeagueId)) {
for (const rec of page) {
const uid = rec.userId || rec.user?.id;
if (uid && ids.has(uid)) {
lmcPoints[uid] = +(rec.ITCPoints || 0);
if (rec.placing != null) lmcRanks[uid] = rec.placing;
}
}
}
} catch (e) {
console.warn('LMC data load failed:', e);
} finally {
lmcLoaded = true;
if (lastState) render(lastState);
}
}
/* ---- Standings (cursor-paginated, circular-cursor safe) ---- */
async function* streamPlacings(leagueId, regionId) {
let base = `${API}/placings?placingsType=player&leagueId=${leagueId}&sortAscending=false&limit=1000`;
if (regionId) base += `&regionId=${regionId}`;
let nextKey = null;
let prevNextKey = undefined;
do {
const url = nextKey ? `${base}&nextKey=${encodeURIComponent(nextKey)}` : base;
const r = await fetch(url, { headers: HEADERS, credentials: "omit" });
if (!r.ok) throw new Error("placings/" + r.status);
const p = await r.json();
const page = Array.isArray(p) ? p : (p.data || p.placings || []);
yield page;
const rawKey = p.nextKey;
nextKey = !rawKey ? null
: typeof rawKey === "string" ? rawKey
: btoa(JSON.stringify(rawKey));
// BCP returns the same cursor when there are no more results — stop when it loops
if (nextKey === prevNextKey) break;
prevNextKey = nextKey;
} while (nextKey);
}
/* ---- Build leaderboard ---- */
function build(rows, ids) {
const kept = [];
for (const rec of rows) {
const uid = rec.userId || (rec.user && rec.user.id);
if (uid && ids.has(uid)) kept.push(rec);
}
kept.sort((a, b) => Number(b.ITCPoints || 0) - Number(a.ITCPoints || 0));
return kept;
}
/* ---- Render ---- */
function render(state) {
lastState = state;
const { kept, rosterSize, updated, live, fromCache } = state;
$("dot").className = "w-2 h-2 rounded-full bg-success animate-pulse transition-colors";
$("updated").textContent = (fromCache ? "Cached " : "Updated ") +
new Date(updated).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
$("meta").textContent = `${kept.length} of ${rosterSize} members placed` + (live ? "" : " · roster (cached)");
$("banner").className = "alert mb-4 hidden";
const champ = $("champion"), strip = $("strip"), empty = $("empty"), tb = $("rows");
tb.innerHTML = "";
if (!kept.length) {
champ.classList.add("hidden"); strip.hidden = true;
empty.classList.remove("hidden");
empty.textContent = "No Gateway Gamers players found in this league's standings yet. " +
"Once members log games here, they'll appear ranked by ITC points.";
return;
}
empty.classList.add("hidden"); strip.hidden = false;
// Champion card
const top = kept[0], tu = top.user || {};
champ.classList.remove("hidden");
$("champ-name").textContent = fullName(tu) || tu.nickname || "—";
$("champ-sub").textContent = `${fmtWpct(top.wins, top.losses, top.ties)} Win Rate · ITC #${top.placing ?? "—"}`;
$("champ-pts").textContent = fmtPts(top.ITCPoints);
// Stat strip
const W = kept.reduce((s, r) => s + (+r.wins || 0), 0),
L = kept.reduce((s, r) => s + (+r.losses || 0), 0),
T = kept.reduce((s, r) => s + (+r.ties || 0), 0),
best = Math.min(...kept.map(r => +r.placing || Infinity));
$("s-placed").textContent = kept.length;
$("s-top").textContent = fmtPts(top.ITCPoints);
$("s-rec").textContent = fmtWpct(W, L, T);
$("s-best").textContent = isFinite(best) ? ("#" + best) : "—";
// Kick off career stat fetches for any players not yet loaded (non-blocking)
loadCareerStats(kept);
// Rows
applySort(kept).forEach((r, i) => {
const u = r.user || {}, name = fullName(u) || u.nickname || "Unknown";
const uid = r.userId || r.user?.id;
const eloMatch = findElo(name, uid);
const lmcRankCell = (() => {
if (!lmcLoaded) return `<td class="text-right font-mono text-base-content/40 hidden sm:table-cell"><span class="opacity-20">…</span></td>`;
const rank = uid ? lmcRanks[uid] : undefined;
return `<td class="text-right font-mono text-base-content/40 hidden sm:table-cell">${rank != null ? '#' + rank : '—'}</td>`;
})();
const tr = document.createElement("tr");
tr.innerHTML =
`<td class="font-mono text-base-content/60">${i === 0 ? `<strong class="text-base-content">1</strong>` : (i + 1)}</td>` +
`<td><span class="font-semibold">${esc(name)}</span>` +
`${u.nickname && fullName(u) ? `<span class="block text-xs text-base-content/40">"${esc(u.nickname)}"</span>` : ""}` +
`<span class="block text-xs opacity-40 sm:hidden"><span class="text-success">${r.wins || 0}</span><span class="text-error">${r.losses || 0}</span>${r.ties || 0} · ITC #${r.placing ?? "—"}</span>` +
`</td>` +
(() => {
const d = uid && careerData[uid];
const careerSub = d == null
? `<span class="block text-xs opacity-20">…</span>`
: (d.w + d.l + d.t) === 0 ? ''
: `<span class="block text-xs opacity-40">${d.w}${d.l}${d.t} career</span>`;
const careerPct = d == null
? `<span class="block text-xs opacity-20">…</span>`
: (d.w + d.l + d.t) === 0 ? ''
: `<span class="block text-xs opacity-40">${fmtWpct(d.w, d.l, d.t)} career</span>`;
const mobilePct = `<span class="block text-xs opacity-40 sm:hidden">${fmtWpct(r.wins, r.losses, r.ties)} win rate</span>`;
const mobileElo = eloMatch ? `<span class="block text-xs opacity-40 sm:hidden">ELO ${Math.round(eloMatch.elo)}</span>` : '';
const mobileLmc = (() => {
if (!uid || !lmcLoaded || lmcPoints[uid] == null) return '';
const rankStr = lmcRanks[uid] != null ? ` · LMC #${lmcRanks[uid]}` : '';
return `<span class="block text-xs opacity-40 sm:hidden">LMC ${fmtPts(lmcPoints[uid])}${rankStr}</span>`;
})();
const lmcCell = (() => {
if (!lmcLoaded) return `<td class="text-right font-mono hidden sm:table-cell"><span class="opacity-20">…</span></td>`;
const pts = uid ? lmcPoints[uid] : undefined;
return `<td class="text-right font-mono hidden sm:table-cell">${pts != null ? fmtPts(pts) : '<span class="text-base-content/40">—</span>'}</td>`;
})();
return `<td class="text-right font-mono font-bold">${fmtPts(r.ITCPoints)}` +
mobilePct + mobileLmc + mobileElo + `</td>` +
lmcCell +
`<td class="text-right font-mono hidden sm:table-cell">` +
`<span class="text-success">${r.wins || 0}</span><span class="text-error">${r.losses || 0}</span>${r.ties || 0}` +
careerSub + `</td>` +
`<td class="text-right font-mono hidden sm:table-cell">${fmtWpct(r.wins, r.losses, r.ties)}` +
careerPct + `</td>`;
})() +
`<td class="text-right font-mono text-base-content/40 hidden sm:table-cell">#${r.placing ?? "—"}</td>` +
lmcRankCell +
(() => {
if (eloMatch === undefined) return `<td class="text-center font-mono hidden sm:table-cell"><span class="opacity-20">…</span></td>`;
if (!eloMatch) return `<td class="text-center font-mono text-base-content/40 hidden sm:table-cell">—</td>`;
return `<td class="text-center font-mono hidden sm:table-cell">${Math.round(eloMatch.elo)}</td>`;
})();
tb.appendChild(tr);
});
}
function esc(s) {
return String(s).replace(/[&<>"]/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
}
/* ---- Cache ---- */
function paintCache() {
try {
const c = JSON.parse(localStorage.getItem(CACHE_KEY) || "null");
if (c && c.kept) render({ ...c, fromCache: true });
} catch (e) {}
}
function saveCache(state) {
try {
localStorage.setItem(CACHE_KEY, JSON.stringify({
kept: state.kept, rosterSize: state.rosterSize, updated: state.updated, live: state.live
}));
} catch (e) {}
}
/* ---- Orchestration ---- */
async function load(force) {
const btn = $("refresh");
btn.disabled = true;
$("dot").className = "w-2 h-2 rounded-full bg-base-content/30 transition-colors";
$("updated").textContent = "Fetching…";
showSkeleton();
try {
if (CONFIG.useSample) { return render({ ...SAMPLE, fromCache: false }); }
const { ids, live } = await getMemberIds(force);
const allRows = [];
// Reset and kick off LMC fetch non-blocking
Object.keys(lmcPoints).forEach(k => delete lmcPoints[k]);
Object.keys(lmcRanks).forEach(k => delete lmcRanks[k]);
loadLmcData(ids);
for await (const page of streamPlacings(CONFIG.leagueId, CONFIG.regionId)) {
allRows.push(...page);
// Render incrementally so results appear as pages arrive
const kept = build(allRows, ids);
if (kept.length) render({ kept, rosterSize: ids.size, updated: Date.now(), live, fromCache: false });
}
const kept = build(allRows, ids);
const state = { kept, rosterSize: ids.size, updated: Date.now(), live };
render({ ...state, fromCache: false });
saveCache(state);
} catch (err) {
failure(err);
} finally {
btn.disabled = false;
}
}
function showSkeleton() {
const tb = $("rows");
tb.innerHTML = "";
for (let i = 0; i < 6; i++) {
const tr = document.createElement("tr");
tr.innerHTML =
`<td><div class="skeleton h-4 w-5"></div></td>` +
`<td><div class="skeleton h-4 w-3/5"></div><div class="skeleton h-3 w-2/5 mt-1 sm:hidden"></div></td>` +
`<td><div class="skeleton h-4 w-12 ml-auto"></div><div class="skeleton h-3 w-10 mt-1 ml-auto sm:hidden"></div></td>` +
`<td class="hidden sm:table-cell"><div class="skeleton h-4 w-12 ml-auto"></div></td>` +
`<td class="hidden sm:table-cell"><div class="skeleton h-4 w-14 ml-auto"></div></td>` +
`<td class="hidden sm:table-cell"><div class="skeleton h-4 w-12 ml-auto"></div></td>` +
`<td class="hidden sm:table-cell"><div class="skeleton h-4 w-10 ml-auto"></div></td>` +
`<td class="hidden sm:table-cell"><div class="skeleton h-4 w-10 ml-auto"></div></td>` +
`<td class="hidden sm:table-cell"><div class="skeleton h-4 w-12 mx-auto"></div></td>`;
tb.appendChild(tr);
}
}
function failure(err) {
$("dot").className = "w-2 h-2 rounded-full bg-base-content/30 transition-colors";
$("updated").textContent = "Couldn't update";
$("rows").innerHTML = "";
$("champion").classList.add("hidden");
$("strip").hidden = true;
const b = $("banner");
const msg = String(err.message || err);
b.className = "alert alert-error mb-4";
b.innerHTML = msg.includes("placings")
? "Couldn't reach the BCP standings feed. Try refreshing, or open the page directly in a browser."
: `Something went wrong: <code class="font-mono text-sm ml-1">${esc(msg)}</code>`;
}
/* ---- Sample (preview only; CONFIG.useSample = true) ---- */
const SAMPLE = (() => {
const mk = (f, l, p, w, ls, t, pl) => ({ user: { firstName: f, lastName: l }, ITCPoints: p, wins: w, losses: ls, ties: t, placing: pl });
const kept = [
mk("Eric", "Darais", 920.7, 19, 4, 0, 453),
mk("Jordan", "Vance", 812.4, 16, 6, 1, 712),
mk("Mara", "Singh", 770.1, 15, 7, 0, 889),
mk("Devon", "Cole", 655.9, 12, 8, 2, 1340),
];
return { kept, rosterSize: 8, updated: Date.now(), live: true };
})();
/* ---- Go ---- */
$("refresh").addEventListener("click", () => load(true));
initSortHeaders();
paintCache();
loadEloData();
load(false);