Files
gateway-gamers.net/src/main.js
T
mandaloreandClaude Sonnet 4.6 3e6a152206 add weekly ELO rank sync from stat-check.com
- scripts/sync-elo.py: downloads Excel sheet, auto-detects columns,
  writes public/elo-data.json keyed by lowercase player name
- .gitlab-ci.yml: sync-elo stage (schedules only) commits the JSON
  then a normal push pipeline handles build + deploy; build/deploy
  skip on scheduled runs to avoid double deployment
- index.html + main.js: ELO Rank column with sort support; loads
  elo-data.json asynchronously and re-renders when ready; fails
  silently if data is missing

Requires one-time setup:
  1. GitLab project token (write_repository) stored as GITLAB_PUSH_TOKEN
  2. Pipeline schedule: cron 0 2 * * 3 (Wednesday 02:00 UTC) on main

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 20:47:30 -05:00

363 lines
14 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",
// 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}` +
`&regionId=${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 =
`<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 hidden sm:table-cell">${fmtWpct(r.wins, r.losses, r.ties)}</td>` +
`<td class="text-right font-mono text-base-content/40 hidden sm:table-cell">#${r.placing ?? "—"}</td>` +
(eloData
? (() => { const e = lookupElo(r); return `<td class="text-right font-mono hidden sm:table-cell">${e ? `#${e.rank.toLocaleString()}` : '<span class="opacity-30">—</span>'}</td>`; })()
: `<td class="text-right font-mono text-base-content/20 hidden sm:table-cell">…</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 = [];
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-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>`;
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();
load(false);
loadElo();