From 8687710d5406a4c35adf1d605dce85eed749d7d7 Mon Sep 17 00:00:00 2001 From: mandalore Date: Sat, 27 Jun 2026 23:48:50 -0500 Subject: [PATCH] feat: add career W-L-T/Win% subtitles and mobile stat folding Ports the career stats feature from the 40K rankings site: fetches all-time W-L-T per player from BCP and shows it as a subtitle under each season stat. Also folds W-L-T, ITC Rank, and Win% into the Player and ITC Points cells on mobile so all stats are visible without horizontal scrolling. Skeleton loader updated to match. Co-Authored-By: Claude Sonnet 4.6 --- src/main.js | 73 ++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 66 insertions(+), 7 deletions(-) diff --git a/src/main.js b/src/main.js index ccefa8c..af1de10 100644 --- a/src/main.js +++ b/src/main.js @@ -60,6 +60,44 @@ const fmtPts = (n) => Number(n || 0).toLocaleString(undefined, { minimumFraction 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 }; + const p = await r.json(); + const items = Array.isArray(p) ? p : (p.data || []); + let w = 0, l = 0, t = 0; + 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); + } + } + return { w, l, t }; +} + +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 }; } + })); + if (lastState) render(lastState); +} + /* ---- Sort state ---- */ let sortState = { col: 'pts', dir: 'desc' }; let lastState = null; @@ -211,6 +249,9 @@ function render(state) { $("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"; @@ -218,11 +259,29 @@ function render(state) { 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)}` + + `${u.nickname && fullName(u) ? `"${esc(u.nickname)}"` : ""}` + + `${r.wins || 0}${r.losses || 0}–${r.ties || 0} · ITC #${r.placing ?? "—"}` + + `` + + (() => { + const uid = r.userId || r.user?.id; + const d = uid && careerData[uid]; + const careerSub = d == null + ? `` + : (d.w + d.l + d.t) === 0 ? '' + : `${d.w}–${d.l}–${d.t} career`; + const careerPct = d == null + ? `` + : (d.w + d.l + d.t) === 0 ? '' + : `${fmtWpct(d.w, d.l, d.t)} career`; + const mobilePct = `${fmtWpct(r.wins, r.losses, r.ties)} win rate`; + return `${fmtPts(r.ITCPoints)}` + + mobilePct + `` + + `` + + `${r.wins || 0}${r.losses || 0}–${r.ties || 0}` + + careerSub + `` + + `${fmtWpct(r.wins, r.losses, r.ties)}` + + careerPct + ``; + })() + `#${r.placing ?? "—"}`; tb.appendChild(tr); }); @@ -286,8 +345,8 @@ function showSkeleton() { const tr = document.createElement("tr"); tr.innerHTML = `
` + - `
` + - `
` + + `
` + + `
` + `
` + `
` + `
`;