275 lines
9.9 KiB
JavaScript
275 lines
9.9 KiB
JavaScript
/* ---- 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: "RtgcexBzqjCM",
|
|||
|
|
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_aos_board_v1";
|
|||
|
|
|
|||
|
|
const $ = (id) => document.getElementById(id);
|
|||
|
|
const fmtPts = (n) => Number(n || 0).toLocaleString(undefined, { minimumFractionDigits: 1, maximumFractionDigits: 1 });
|
|||
|
|
const fullName = (u = {}) => [u.firstName, u.lastName].filter(Boolean).join(" ").trim();
|
|||
|
|
|
|||
|
|
/* ---- 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) {
|
|||
|
|
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 = `${top.wins || 0}–${top.losses || 0}–${top.ties || 0} · 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 = `${W}–${L}–${T}`;
|
|||
|
|
$("s-best").textContent = isFinite(best) ? ("#" + best) : "—";
|
|||
|
|
|
|||
|
|
// Rows
|
|||
|
|
kept.forEach((r, i) => {
|
|||
|
|
const u = r.user || {}, name = fullName(u) || u.nickname || "Unknown";
|
|||
|
|
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>` : ""}</td>` +
|
|||
|
|
`<td class="text-right font-mono font-bold">${fmtPts(r.ITCPoints)}</td>` +
|
|||
|
|
`<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}</td>` +
|
|||
|
|
`<td class="text-right font-mono text-base-content/40 hidden sm:table-cell">#${r.placing ?? "—"}</td>`;
|
|||
|
|
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 =
|
|||
|
|
`<td><div class="skeleton h-4 w-5"></div></td>` +
|
|||
|
|
`<td><div class="skeleton h-4 w-3/5"></div></td>` +
|
|||
|
|
`<td><div class="skeleton h-4 w-12 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>`;
|
|||
|
|
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));
|
|||
|
|
paintCache();
|
|||
|
|
load(false);
|