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 <noreply@anthropic.com>
This commit is contained in:
+65
-6
@@ -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 =
|
||||
`<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>` +
|
||||
`${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 uid = r.userId || r.user?.id;
|
||||
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>`;
|
||||
return `<td class="text-right font-mono font-bold">${fmtPts(r.ITCPoints)}` +
|
||||
mobilePct + `</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>` +
|
||||
`<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>`;
|
||||
tb.appendChild(tr);
|
||||
});
|
||||
@@ -286,8 +345,8 @@ function showSkeleton() {
|
||||
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><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-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>`;
|
||||
|
||||
Reference in New Issue
Block a user