/* ---- 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' }, 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 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); } } } 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 += `®ionId=${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 tr = document.createElement("tr"); tr.innerHTML = `
${esc(msg)}`;
}
/* ---- 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);