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

481 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ---- 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 += `&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 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 = (uid && lmcLoaded && lmcPoints[uid] != null) ? `<span class="block text-xs opacity-40 sm:hidden">LMC ${fmtPts(lmcPoints[uid])}</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>` +
(() => {
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]);
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-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);