Files
gateway-gamers.net/sites/40k-rankings/gateway_gamers_leaderboard.html
T

405 lines
17 KiB
HTML
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.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Gateway Gamers — 40K ITC Standings</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Saira+Condensed:wght@500;600;700&family=Inter:wght@400;500;600&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
<style>
/* ---- Palette: "command ledger" — gunmetal base, signal amber ---- */
:root{
--ink:#0d1014; --panel:#151b22; --panel-2:#1b232c; --edge:#28323d;
--steel:#8a97a6; --steel-dim:#5d6873; --bone:#eceef1;
--signal:#e0a73e; --signal-dim:#7a5e25;
--win:#5fa88c; --loss:#bf5a52;
--r:10px;
}
*{box-sizing:border-box}
html,body{margin:0}
body{
background:
radial-gradient(1200px 600px at 80% -10%, #1a222c 0%, transparent 60%),
var(--ink);
color:var(--bone);
font-family:Inter,system-ui,sans-serif;
-webkit-font-smoothing:antialiased;
line-height:1.45;
min-height:100vh;
}
.wrap{max-width:920px;margin:0 auto;padding:28px 20px 64px}
/* ---- Masthead ---- */
.masthead{display:flex;align-items:center;gap:16px;padding-bottom:20px;
border-bottom:1px solid var(--edge);flex-wrap:wrap}
.crest{width:54px;height:54px;flex:0 0 auto;display:grid;place-items:center;
border:1.5px solid var(--signal);color:var(--signal);border-radius:50%;
font-family:"Saira Condensed",sans-serif;font-weight:700;font-size:22px;
letter-spacing:.5px;background:rgba(224,167,62,.06)}
.titles{flex:1 1 240px;min-width:0}
h1{font-family:"Saira Condensed",sans-serif;font-weight:700;letter-spacing:.5px;
text-transform:uppercase;margin:0;font-size:clamp(28px,5vw,42px);line-height:.95}
.kicker{margin:4px 0 0;color:var(--steel);font-size:13px;letter-spacing:.18em;
text-transform:uppercase}
.status{display:flex;align-items:center;gap:12px;margin-left:auto}
.live{display:flex;align-items:center;gap:7px;color:var(--steel);font-size:12.5px}
.dot{width:8px;height:8px;border-radius:50%;background:var(--steel-dim)}
.dot.on{background:var(--win);box-shadow:0 0 0 0 rgba(95,168,140,.6);
animation:pulse 2.4s infinite}
@keyframes pulse{0%{box-shadow:0 0 0 0 rgba(95,168,140,.5)}
70%{box-shadow:0 0 0 7px rgba(95,168,140,0)}100%{box-shadow:0 0 0 0 transparent}}
button#refresh{font-family:"Saira Condensed",sans-serif;text-transform:uppercase;
letter-spacing:.1em;font-weight:600;font-size:13px;color:var(--ink);
background:var(--signal);border:0;border-radius:6px;padding:8px 14px;cursor:pointer}
button#refresh:hover{filter:brightness(1.08)}
button#refresh:disabled{opacity:.5;cursor:wait}
button#refresh:focus-visible{outline:2px solid var(--bone);outline-offset:2px}
/* ---- Team stat strip ---- */
.strip{display:grid;grid-template-columns:repeat(4,1fr);gap:1px;margin:22px 0;
background:var(--edge);border:1px solid var(--edge);border-radius:var(--r);overflow:hidden}
.stat{background:var(--panel);padding:14px 16px}
.stat .n{font-family:"Space Mono",monospace;font-size:22px;font-weight:700}
.stat .l{font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:var(--steel);margin-top:2px}
@media(max-width:560px){.strip{grid-template-columns:repeat(2,1fr)}}
/* ---- Banner (warnings/errors) ---- */
.banner{border-radius:var(--r);padding:12px 16px;margin:16px 0;font-size:14px;
border:1px solid var(--edge);background:var(--panel);display:none}
.banner.show{display:block}
.banner.warn{border-color:var(--signal-dim);background:rgba(224,167,62,.08)}
.banner.err{border-color:#5a2f2c;background:rgba(191,90,82,.1)}
.banner b{color:var(--signal)}
.banner code{font-family:"Space Mono",monospace;font-size:12.5px;color:var(--bone)}
/* ---- Champion ---- */
.champion{display:none;align-items:center;gap:18px;margin:18px 0;
background:linear-gradient(100deg,var(--panel-2),var(--panel));
border:1px solid var(--signal-dim);border-radius:var(--r);padding:18px 20px;position:relative}
.champion.show{display:flex}
.champion .rk{font-family:"Saira Condensed",sans-serif;font-weight:700;font-size:42px;
color:var(--signal);line-height:1;width:46px;text-align:center}
.champion .who{flex:1 1 auto;min-width:0}
.champion .name{font-family:"Saira Condensed",sans-serif;font-weight:600;
font-size:26px;text-transform:uppercase;letter-spacing:.5px}
.champion .sub{color:var(--steel);font-size:13px}
.champion .pts{font-family:"Space Mono",monospace;font-weight:700;font-size:30px;color:var(--signal)}
.champion .pts span{display:block;font-family:Inter;font-weight:500;font-size:11px;
letter-spacing:.14em;text-transform:uppercase;color:var(--steel);text-align:right}
.laurel{position:absolute;top:10px;right:14px;font-size:11px;letter-spacing:.18em;
text-transform:uppercase;color:var(--signal)}
/* ---- Board ---- */
table{width:100%;border-collapse:collapse;margin-top:8px}
thead th{font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:var(--steel-dim);
text-align:left;padding:0 12px 10px;font-weight:600}
thead th.num{text-align:right}
tbody tr{border-top:1px solid var(--edge);opacity:0;transform:translateY(6px);
animation:rise .4s ease forwards}
@keyframes rise{to{opacity:1;transform:none}}
tbody td{padding:13px 12px;vertical-align:middle}
.c-rank{font-family:"Space Mono",monospace;color:var(--steel);width:48px}
.c-rank b{color:var(--bone)}
.c-name{font-weight:600}
.c-name .nick{display:block;font-weight:400;font-size:12px;color:var(--steel-dim)}
.c-pts{font-family:"Space Mono",monospace;font-weight:700;text-align:right;font-size:16px}
.c-rec{font-family:"Space Mono",monospace;text-align:right;color:var(--steel);font-size:13px;white-space:nowrap}
.c-rec .w{color:var(--win)}.c-rec .l{color:var(--loss)}
.c-lg{font-family:"Space Mono",monospace;text-align:right;color:var(--steel-dim);font-size:13px}
@media(max-width:560px){.hide-sm{display:none}.c-pts{font-size:15px}}
/* ---- States ---- */
.empty{display:none;text-align:center;color:var(--steel);padding:48px 20px}
.empty.show{display:block}
.skeleton td{padding:16px 12px}
.sk{height:14px;border-radius:4px;background:linear-gradient(90deg,var(--panel),var(--panel-2),var(--panel));
background-size:200% 100%;animation:shimmer 1.3s infinite}
@keyframes shimmer{to{background-position:-200% 0}}
footer{margin-top:28px;padding-top:16px;border-top:1px solid var(--edge);
color:var(--steel-dim);font-size:12px;display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap}
footer a{color:var(--steel)}
@media(prefers-reduced-motion:reduce){
*{animation:none!important}
tbody tr{opacity:1;transform:none}
}
</style>
</head>
<body>
<div class="wrap">
<header class="masthead">
<div class="crest">GG</div>
<div class="titles">
<h1>Gateway Gamers</h1>
<p class="kicker">Warhammer 40,000 · ITC Standings</p>
</div>
<div class="status">
<span class="live"><span class="dot" id="dot"></span><span id="updated">Loading…</span></span>
<button id="refresh">Refresh</button>
</div>
</header>
<section class="strip" id="strip" aria-label="Team summary" hidden>
<div class="stat"><div class="n" id="s-placed"></div><div class="l">Players placed</div></div>
<div class="stat"><div class="n" id="s-top"></div><div class="l">Top ITC points</div></div>
<div class="stat"><div class="n" id="s-rec"></div><div class="l">Combined WLT</div></div>
<div class="stat"><div class="n" id="s-best"></div><div class="l">Best league rank</div></div>
</section>
<div class="banner" id="banner"></div>
<section class="champion" id="champion" aria-label="Top player">
<div class="rk">1</div>
<div class="who">
<div class="name" id="champ-name"></div>
<div class="sub" id="champ-sub"></div>
</div>
<div class="pts" id="champ-pts"></div>
<div class="laurel">Team Leader</div>
</section>
<table id="board">
<thead>
<tr>
<th class="c-rank">#</th>
<th>Player</th>
<th class="num">ITC Points</th>
<th class="num hide-sm">WLT</th>
<th class="num hide-sm">League Rank</th>
</tr>
</thead>
<tbody id="rows"></tbody>
</table>
<div class="empty" id="empty"></div>
<footer>
<span>Data from Best Coast Pairings · live on refresh</span>
<span id="meta"></span>
</footer>
</div>
<script>
/* ============================================================
CONFIG — the only block you edit
============================================================ */
const CONFIG = {
teamId: "2iGDVMgX0a",
leagueId: "BYaaUfKum7z0",
regionId: "VgQKgqmTPU",
// Fallback roster, used only if the live team fetch needs auth.
// Paste the member user IDs your Python --debug run assembled, e.g.
// ["yHkqr1DICY", "PO4GKS85dz", ...]
memberIds: [
// "yHkqr1DICY",
],
// Single-call sizes to try (BCP ignores offset; rejects oversize with 409).
limitLadder: [14000, 12000, 10000, 8000, 6000, 4000, 3000],
// 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_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, cache: force?"reload":"default"});
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, name:t.name, live:true};
throw new Error("empty");
}catch(e){
if(CONFIG.memberIds.length) return {ids:new Set(CONFIG.memberIds), name:"Gateway Gamers", live:false};
throw e;
}
}
/* ---- Standings (largest accepted limit, one call) ---- */
async function getPlacings(force){
const base = `${API}/placings?placingsType=player&leagueId=${CONFIG.leagueId}`+
`&regionId=${CONFIG.regionId}&sortAscending=false`;
for(const lim of CONFIG.limitLadder){
const r = await fetch(`${base}&limit=${lim}`, {headers:HEADERS, cache: force?"reload":"default"});
if(r.status===409) continue;
if(!r.ok) throw new Error("placings/"+r.status);
const p = await r.json();
const rows = Array.isArray(p) ? p : (p.data||p.placings||[]);
return {rows, limit:lim, truncated: rows.length>=lim};
}
throw new Error("placings/limit-all-rejected");
}
/* ---- Build leaderboard ---- */
function build(rows, ids){
const kept=[], matched=new Set();
for(const rec of rows){
const u = rec.user||{};
const uid = rec.userId||u.id;
if(uid && ids.has(uid)){ kept.push(rec); matched.add(uid); }
}
kept.sort((a,b)=> Number(b.ITCPoints||0)-Number(a.ITCPoints||0));
return {kept, matchedCount:matched.size};
}
/* ---- Render ---- */
function render(state){
const {kept, rosterSize, updated, truncated, limit, live, fromCache} = state;
$("dot").className = "dot on";
$("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 list)");
// Banner
const b=$("banner"); b.className="banner";
if(truncated){
b.classList.add("show","warn");
b.innerHTML = `<b>Heads up:</b> the league returned the full <code>limit=${limit}</code> rows, `+
`so some lower-ranked players may be cut off. Tell the developer so the paging cursor can be added.`;
}
const champ=$("champion"), strip=$("strip"), empty=$("empty"), tb=$("rows");
tb.innerHTML="";
if(!kept.length){
champ.classList.remove("show"); strip.hidden=true;
empty.classList.add("show");
empty.innerHTML = "No Gateway Gamers players found in this leagues standings yet. "+
"Once members log games here, theyll appear ranked by ITC points.";
return;
}
empty.classList.remove("show"); strip.hidden=false;
// Champion card
const top=kept[0], tu=top.user||{};
champ.classList.add("show");
$("champ-name").textContent = fullName(tu)||tu.nickname||"—";
$("champ-sub").textContent = `${top.wins||0}${top.losses||0}${top.ties||0} · league #${top.placing??"—"}`;
$("champ-pts").innerHTML = `${fmtPts(top.ITCPoints)}<span>ITC points</span>`;
// 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.style.animationDelay=(Math.min(i,20)*22)+"ms";
tr.innerHTML =
`<td class="c-rank">${i===0?"<b>1</b>":(i+1)}</td>`+
`<td class="c-name">${esc(name)}${u.nickname&&fullName(u)?`<span class="nick">"${esc(u.nickname)}"</span>`:""}</td>`+
`<td class="c-pts">${fmtPts(r.ITCPoints)}</td>`+
`<td class="c-rec hide-sm"><span class="w">${r.wins||0}</span><span class="l">${r.losses||0}</span>${r.ties||0}</td>`+
`<td class="c-lg hide-sm">#${r.placing??"—"}</td>`;
tb.appendChild(tr);
});
}
function esc(s){return String(s).replace(/[&<>"]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]));}
/* ---- Orchestration ---- */
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,
truncated:state.truncated, limit:state.limit, live:state.live
})); }catch(e){}
}
async function load(force){
const btn=$("refresh"); btn.disabled=true; $("dot").className="dot";
$("updated").textContent="Fetching…";
showSkeleton();
try{
if(CONFIG.useSample){ return render({...SAMPLE, fromCache:false}); }
const {ids,name,live} = await getMemberIds(force);
const {rows,limit,truncated} = await getPlacings(force);
const {kept} = build(rows, ids);
const state={kept, rosterSize:ids.size, updated:Date.now(), truncated, limit, 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.className="skeleton";
tr.innerHTML=`<td><div class="sk" style="width:20px"></div></td>
<td><div class="sk" style="width:60%"></div></td>
<td><div class="sk" style="width:50px;margin-left:auto"></div></td>
<td class="hide-sm"><div class="sk" style="width:50px;margin-left:auto"></div></td>
<td class="hide-sm"><div class="sk" style="width:40px;margin-left:auto"></div></td>`;
tb.appendChild(tr);
}
}
function failure(err){
$("dot").className="dot";
$("updated").textContent="Couldnt update";
$("rows").innerHTML=""; $("champion").classList.remove("show"); $("strip").hidden=true;
const b=$("banner"); b.className="banner show err";
const msg=String(err.message||err);
if(msg.startsWith("auth")){
b.innerHTML="The team roster needs a login. Add the member IDs to "+
"<code>CONFIG.memberIds</code> in this file so the page can work without one.";
}else if(msg.includes("placings")){
b.innerHTML="Couldnt reach the BCP standings feed. If you opened this file inside a "+
"preview that blocks outside requests, host it (GitHub Pages, Netlify, your store site) "+
"or open the file directly in a browser.";
}else{
b.innerHTML="Something went wrong reaching Best Coast Pairings: <code>"+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(), truncated:false, limit:14000, live:true};
})();
/* ---- Go ---- */
$("refresh").addEventListener("click",()=>load(true));
paintCache();
load(false);
</script>
</body>
</html>