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.
This commit is contained in:
+1
-1
@@ -144,8 +144,8 @@
|
|||||||
<th>Player</th>
|
<th>Player</th>
|
||||||
<th>Faction</th>
|
<th>Faction</th>
|
||||||
<th class="hidden sm:table-cell">Disposition</th>
|
<th class="hidden sm:table-cell">Disposition</th>
|
||||||
<th class="text-center">Checked In</th>
|
|
||||||
<th class="text-right">ITC Rank</th>
|
<th class="text-right">ITC Rank</th>
|
||||||
|
<th class="text-right">Career W%</th>
|
||||||
<th class="text-right">List</th>
|
<th class="text-right">List</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|||||||
+53
-13
@@ -87,6 +87,36 @@ async function* streamPlacings(leagueId, regionId) {
|
|||||||
} while (nextKey);
|
} 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) {
|
async function loadItcRanks(userIds) {
|
||||||
const ranks = new Map();
|
const ranks = new Map();
|
||||||
if (!userIds.size) return ranks;
|
if (!userIds.size) return ranks;
|
||||||
@@ -309,6 +339,7 @@ const $ = id => document.getElementById(id);
|
|||||||
const esc = s => String(s).replace(/[&<>"]/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
const esc = s => String(s).replace(/[&<>"]/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
||||||
const fullName = (u = {}) => [u.firstName, u.lastName].filter(Boolean).join(" ").trim();
|
const fullName = (u = {}) => [u.firstName, u.lastName].filter(Boolean).join(" ").trim();
|
||||||
const pct = (n, d) => d ? Math.round(n / d * 100) + "%" : "—";
|
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) {
|
function parseEventId(input) {
|
||||||
const s = String(input || "").trim();
|
const s = String(input || "").trim();
|
||||||
@@ -496,17 +527,17 @@ function renderFactionBreakdown(container, factionCounts, dispositionsByFaction)
|
|||||||
}).join("");
|
}).join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
function playerRowHTML(r, itcRanks, itcLoading) {
|
function playerRowHTML(r, itcRanks, itcLoading, careerRecords, careerLoading) {
|
||||||
const name = fullName(r.user || {}) || r.user?.nickname || "Unknown";
|
const name = fullName(r.user || {}) || r.user?.nickname || "Unknown";
|
||||||
const checkedBadge = r.checkedIn
|
|
||||||
? `<span class="badge badge-success badge-sm">Yes</span>`
|
|
||||||
: `<span class="badge badge-ghost badge-sm">No</span>`;
|
|
||||||
const listCell = r.listUrl
|
const listCell = r.listUrl
|
||||||
? `<a href="${BCP_ORIGIN}${r.listUrl}" target="_blank" rel="noopener" class="link link-primary text-sm">View</a>`
|
? `<a href="${BCP_ORIGIN}${r.listUrl}" target="_blank" rel="noopener" class="link link-primary text-sm">View</a>`
|
||||||
: `<span class="opacity-30 text-sm">—</span>`;
|
: `<span class="opacity-30 text-sm">—</span>`;
|
||||||
const uid = r.userId || r.user?.id;
|
const uid = r.userId || r.user?.id;
|
||||||
const rank = itcRanks.get(uid);
|
const rank = itcRanks.get(uid);
|
||||||
const rankLabel = itcLoading ? "…" : (rank != null ? `#${rank}` : "—");
|
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 => `<span class="italic underline decoration-dotted decoration-base-content/40 underline-offset-2 cursor-help" title="Inferred from submitted list">${text}</span>`;
|
const inferred = text => `<span class="italic underline decoration-dotted decoration-base-content/40 underline-offset-2 cursor-help" title="Inferred from submitted list">${text}</span>`;
|
||||||
const factionCell = r._backfilledFaction ? inferred(esc(r.faction?.name || "—")) : esc(r.faction?.name || "—");
|
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 || "—");
|
const dispositionCell = r._backfilledDisposition ? inferred(esc(r.subFaction?.name || "—")) : esc(r.subFaction?.name || "—");
|
||||||
@@ -515,8 +546,8 @@ function playerRowHTML(r, itcRanks, itcLoading) {
|
|||||||
<td class="font-semibold">${esc(name)}</td>
|
<td class="font-semibold">${esc(name)}</td>
|
||||||
<td class="text-sm">${factionCell}</td>
|
<td class="text-sm">${factionCell}</td>
|
||||||
<td class="text-sm opacity-70 hidden sm:table-cell">${dispositionCell}</td>
|
<td class="text-sm opacity-70 hidden sm:table-cell">${dispositionCell}</td>
|
||||||
<td class="text-center">${checkedBadge}</td>
|
|
||||||
<td class="text-right font-mono text-sm">${rankLabel}</td>
|
<td class="text-right font-mono text-sm">${rankLabel}</td>
|
||||||
|
<td class="text-right font-mono text-sm"${wpctTitle}>${wpctLabel}</td>
|
||||||
<td class="text-right">${listCell}</td>
|
<td class="text-right">${listCell}</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}
|
}
|
||||||
@@ -527,7 +558,7 @@ function memberMatches(m, teamName, q) {
|
|||||||
return hay.includes(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 q = (filterText || "").trim().toLowerCase();
|
||||||
|
|
||||||
const blocks = teams.map(t => {
|
const blocks = teams.map(t => {
|
||||||
@@ -564,12 +595,12 @@ function renderTeamList(teams, itcRanks, itcLoading, filterText) {
|
|||||||
<th>Player</th>
|
<th>Player</th>
|
||||||
<th>Faction</th>
|
<th>Faction</th>
|
||||||
<th class="hidden sm:table-cell">Disposition</th>
|
<th class="hidden sm:table-cell">Disposition</th>
|
||||||
<th class="text-center">Checked In</th>
|
|
||||||
<th class="text-right">ITC Rank</th>
|
<th class="text-right">ITC Rank</th>
|
||||||
|
<th class="text-right">Career W%</th>
|
||||||
<th class="text-right">List</th>
|
<th class="text-right">List</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>${shown.map(m => playerRowHTML(m, itcRanks, itcLoading)).join("")}</tbody>
|
<tbody>${shown.map(m => playerRowHTML(m, itcRanks, itcLoading, careerRecords, careerLoading)).join("")}</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -580,7 +611,7 @@ function renderTeamList(teams, itcRanks, itcLoading, filterText) {
|
|||||||
`<p class="text-sm opacity-50">No teams match${q ? ` "${esc(filterText)}"` : ""}.</p>`;
|
`<p class="text-sm opacity-50">No teams match${q ? ` "${esc(filterText)}"` : ""}.</p>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPlayers(roster, itcRanks, itcLoading, filterText) {
|
function renderPlayers(roster, itcRanks, itcLoading, careerRecords, careerLoading, filterText) {
|
||||||
const q = (filterText || "").trim().toLowerCase();
|
const q = (filterText || "").trim().toLowerCase();
|
||||||
const rows = roster.filter(r => {
|
const rows = roster.filter(r => {
|
||||||
if (!q) return true;
|
if (!q) return true;
|
||||||
@@ -589,7 +620,7 @@ function renderPlayers(roster, itcRanks, itcLoading, filterText) {
|
|||||||
return hay.includes(q);
|
return hay.includes(q);
|
||||||
}).sort((a, b) => fullName(a.user || {}).localeCompare(fullName(b.user || {})));
|
}).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
|
$("player-count").textContent = q
|
||||||
? `Showing ${rows.length} of ${roster.length} players`
|
? `Showing ${rows.length} of ${roster.length} players`
|
||||||
@@ -617,7 +648,7 @@ function clearBanner() {
|
|||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
if (!state) return;
|
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 =>
|
const roster = applyBackfill(state.roster, backfill).map(r =>
|
||||||
r.subFaction?.name
|
r.subFaction?.name
|
||||||
? { ...r, subFaction: { ...r.subFaction, name: correctDisposition(r.subFaction.name) } }
|
? { ...r, subFaction: { ...r.subFaction, name: correctDisposition(r.subFaction.name) } }
|
||||||
@@ -670,13 +701,13 @@ function render() {
|
|||||||
|
|
||||||
const filterText = $("player-filter").value;
|
const filterText = $("player-filter").value;
|
||||||
if (isTeamEvent) {
|
if (isTeamEvent) {
|
||||||
renderTeamList(teams, itcRanks, itcLoading, filterText);
|
renderTeamList(teams, itcRanks, itcLoading, careerRecords, careerLoading, filterText);
|
||||||
const q = filterText.trim();
|
const q = filterText.trim();
|
||||||
$("player-count").textContent = q
|
$("player-count").textContent = q
|
||||||
? `Filtering ${teams.length} teams for "${q}"`
|
? `Filtering ${teams.length} teams for "${q}"`
|
||||||
: `${roster.length} players across ${teams.length} teams`;
|
: `${roster.length} players across ${teams.length} teams`;
|
||||||
} else {
|
} else {
|
||||||
renderPlayers(roster, itcRanks, itcLoading, filterText);
|
renderPlayers(roster, itcRanks, itcLoading, careerRecords, careerLoading, filterText);
|
||||||
}
|
}
|
||||||
|
|
||||||
$("loading").classList.add("hidden");
|
$("loading").classList.add("hidden");
|
||||||
@@ -726,6 +757,7 @@ async function loadEvent(rawInput) {
|
|||||||
state = {
|
state = {
|
||||||
event, roster, teamplayers, isTeamEvent,
|
event, roster, teamplayers, isTeamEvent,
|
||||||
itcRanks: new Map(), itcLoading: true,
|
itcRanks: new Map(), itcLoading: true,
|
||||||
|
careerRecords: new Map(), careerLoading: true,
|
||||||
backfill: new Map(), backfillStatus: "idle", backfillAttempted: 0,
|
backfill: new Map(), backfillStatus: "idle", backfillAttempted: 0,
|
||||||
};
|
};
|
||||||
render();
|
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);
|
startBackfill(event);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = String(err.message || err);
|
const msg = String(err.message || err);
|
||||||
|
|||||||
Reference in New Issue
Block a user