From 420471942a823ed1011e4be967ac64f52a53e689 Mon Sep 17 00:00:00 2001 From: mandalore Date: Thu, 6 Aug 2026 18:23:57 -0500 Subject: [PATCH] feat: show career win% per player, drop unneeded Checked In column Career record is fetched the same way the 40k-rankings site does: sum wins/losses/ties across a player's full BCP placings history. --- index.html | 2 +- src/main.js | 66 ++++++++++++++++++++++++++++++++++++++++++----------- 2 files changed, 54 insertions(+), 14 deletions(-) diff --git a/index.html b/index.html index c579a1d..506dc47 100644 --- a/index.html +++ b/index.html @@ -144,8 +144,8 @@ Player Faction Disposition - Checked In ITC Rank + Career W% List diff --git a/src/main.js b/src/main.js index 1e40e71..2c861bd 100644 --- a/src/main.js +++ b/src/main.js @@ -87,6 +87,36 @@ async function* streamPlacings(leagueId, regionId) { } while (nextKey); } +// Career win/loss/tie record, summed across every event in a player's BCP +// placings history (same source and shape the 40k-rankings site uses for +// its own win% column). +async function fetchCareerRecord(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; + w += +(v.wins || 0); + l += +(v.losses || 0); + t += +(v.ties || 0); + } + return { w, l, t }; +} + +async function loadCareerRecords(userIds) { + const records = new Map(); + await Promise.allSettled([...userIds].map(async uid => { + try { records.set(uid, await fetchCareerRecord(uid)); } + catch { records.set(uid, { w: 0, l: 0, t: 0 }); } + })); + return records; +} + async function loadItcRanks(userIds) { const ranks = new Map(); if (!userIds.size) return ranks; @@ -309,6 +339,7 @@ const $ = id => document.getElementById(id); const esc = s => String(s).replace(/[&<>"]/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); const fullName = (u = {}) => [u.firstName, u.lastName].filter(Boolean).join(" ").trim(); const pct = (n, d) => d ? Math.round(n / d * 100) + "%" : "—"; +const fmtWpct = (w, l, t) => { const g = (+w || 0) + (+l || 0) + (+t || 0); return g ? ((+w || 0) / g * 100).toFixed(1) + "%" : "—"; }; function parseEventId(input) { const s = String(input || "").trim(); @@ -496,17 +527,17 @@ function renderFactionBreakdown(container, factionCounts, dispositionsByFaction) }).join(""); } -function playerRowHTML(r, itcRanks, itcLoading) { +function playerRowHTML(r, itcRanks, itcLoading, careerRecords, careerLoading) { const name = fullName(r.user || {}) || r.user?.nickname || "Unknown"; - const checkedBadge = r.checkedIn - ? `Yes` - : `No`; const listCell = r.listUrl ? `View` : ``; const uid = r.userId || r.user?.id; const rank = itcRanks.get(uid); const rankLabel = itcLoading ? "…" : (rank != null ? `#${rank}` : "—"); + const record = careerRecords.get(uid); + const wpctLabel = careerLoading ? "…" : fmtWpct(record?.w, record?.l, record?.t); + const wpctTitle = record ? ` title="${record.w || 0}-${record.l || 0}-${record.t || 0} career"` : ""; const inferred = text => `${text}`; const factionCell = r._backfilledFaction ? inferred(esc(r.faction?.name || "—")) : esc(r.faction?.name || "—"); const dispositionCell = r._backfilledDisposition ? inferred(esc(r.subFaction?.name || "—")) : esc(r.subFaction?.name || "—"); @@ -515,8 +546,8 @@ function playerRowHTML(r, itcRanks, itcLoading) { ${esc(name)} ${factionCell} ${dispositionCell} - ${checkedBadge} ${rankLabel} + ${wpctLabel} ${listCell} `; } @@ -527,7 +558,7 @@ function memberMatches(m, teamName, q) { return hay.includes(q); } -function renderTeamList(teams, itcRanks, itcLoading, filterText) { +function renderTeamList(teams, itcRanks, itcLoading, careerRecords, careerLoading, filterText) { const q = (filterText || "").trim().toLowerCase(); const blocks = teams.map(t => { @@ -564,12 +595,12 @@ function renderTeamList(teams, itcRanks, itcLoading, filterText) { Player Faction Disposition - Checked In ITC Rank + Career W% List - ${shown.map(m => playerRowHTML(m, itcRanks, itcLoading)).join("")} + ${shown.map(m => playerRowHTML(m, itcRanks, itcLoading, careerRecords, careerLoading)).join("")} @@ -580,7 +611,7 @@ function renderTeamList(teams, itcRanks, itcLoading, filterText) { `

No teams match${q ? ` "${esc(filterText)}"` : ""}.

`; } -function renderPlayers(roster, itcRanks, itcLoading, filterText) { +function renderPlayers(roster, itcRanks, itcLoading, careerRecords, careerLoading, filterText) { const q = (filterText || "").trim().toLowerCase(); const rows = roster.filter(r => { if (!q) return true; @@ -589,7 +620,7 @@ function renderPlayers(roster, itcRanks, itcLoading, filterText) { return hay.includes(q); }).sort((a, b) => fullName(a.user || {}).localeCompare(fullName(b.user || {}))); - $("player-rows").innerHTML = rows.map(r => playerRowHTML(r, itcRanks, itcLoading)).join(""); + $("player-rows").innerHTML = rows.map(r => playerRowHTML(r, itcRanks, itcLoading, careerRecords, careerLoading)).join(""); $("player-count").textContent = q ? `Showing ${rows.length} of ${roster.length} players` @@ -617,7 +648,7 @@ function clearBanner() { function render() { if (!state) return; - const { event, teamplayers, isTeamEvent, itcRanks, itcLoading, backfill, backfillStatus } = state; + const { event, teamplayers, isTeamEvent, itcRanks, itcLoading, careerRecords, careerLoading, backfill, backfillStatus } = state; const roster = applyBackfill(state.roster, backfill).map(r => r.subFaction?.name ? { ...r, subFaction: { ...r.subFaction, name: correctDisposition(r.subFaction.name) } } @@ -670,13 +701,13 @@ function render() { const filterText = $("player-filter").value; if (isTeamEvent) { - renderTeamList(teams, itcRanks, itcLoading, filterText); + renderTeamList(teams, itcRanks, itcLoading, careerRecords, careerLoading, filterText); const q = filterText.trim(); $("player-count").textContent = q ? `Filtering ${teams.length} teams for "${q}"` : `${roster.length} players across ${teams.length} teams`; } else { - renderPlayers(roster, itcRanks, itcLoading, filterText); + renderPlayers(roster, itcRanks, itcLoading, careerRecords, careerLoading, filterText); } $("loading").classList.add("hidden"); @@ -726,6 +757,7 @@ async function loadEvent(rawInput) { state = { event, roster, teamplayers, isTeamEvent, itcRanks: new Map(), itcLoading: true, + careerRecords: new Map(), careerLoading: true, backfill: new Map(), backfillStatus: "idle", backfillAttempted: 0, }; render(); @@ -739,6 +771,14 @@ async function loadEvent(rawInput) { } }); + loadCareerRecords(userIds).then(records => { + if (state && state.event === event) { + state.careerRecords = records; + state.careerLoading = false; + render(); + } + }); + startBackfill(event); } catch (err) { const msg = String(err.message || err);