/* ---- 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", // 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(); /* ---- ELO data ---- */ let eloData = null; async function loadElo() { try { const r = await fetch('/elo-data.json'); if (!r.ok) return; const d = await r.json(); if (d && d.byName && d.count > 0) { eloData = d; if (lastState) render(lastState); } } catch (e) { /* ELO is optional — fail silently */ } } function lookupElo(r) { if (!eloData) return null; const name = fullName(r.user || {}).toLowerCase().trim(); if (eloData.byName[name]) return eloData.byName[name]; // Fall back to first + last only (strips middle initials) const parts = name.split(/\s+/).filter(Boolean); if (parts.length > 2) { const short = parts[0] + ' ' + parts[parts.length - 1]; if (eloData.byName[short]) return eloData.byName[short]; } return 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' }, elo: { key: r => { const e = lookupElo(r); return e ? e.rank : Infinity; }, defaultDir: 'asc' }, }; 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; } } /* ---- Standings (cursor-paginated, circular-cursor safe) ---- */ async function* streamPlacings() { const base = `${API}/placings?placingsType=player&leagueId=${CONFIG.leagueId}` + `®ionId=${CONFIG.regionId}&sortAscending=false&limit=1000`; 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) : "—"; // Rows applySort(kept).forEach((r, i) => { const u = r.user || {}, name = fullName(u) || u.nickname || "Unknown"; const tr = document.createElement("tr"); tr.innerHTML = `${i === 0 ? `1` : (i + 1)}` + `${esc(name)}` + `${u.nickname && fullName(u) ? `"${esc(u.nickname)}"` : ""}` + `${fmtPts(r.ITCPoints)}` + `` + `${r.wins || 0}${r.losses || 0}–${r.ties || 0}` + `${fmtWpct(r.wins, r.losses, r.ties)}` + `#${r.placing ?? "—"}` + (eloData ? (() => { const e = lookupElo(r); return `${e ? `#${e.rank.toLocaleString()}` : ''}`; })() : `…`); tb.appendChild(tr); }); } function esc(s) { return String(s).replace(/[&<>"]/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """ }[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 = []; for await (const page of streamPlacings()) { 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 = `
` + `
` + `
` + `
` + `
` + `
` + `
`; 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: ${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(); load(false); loadElo();