feat: add Stat-Check ELO column with daily auto-refresh

- Scraper (statcheck-elo-scraper/refresh_elo.py) downloads the stat-check
  ELO workbook from OneDrive and builds public/elo-data.json
- GitHub Actions workflow (.github/workflows/refresh-elo.yml) runs daily
  at 06:00 UTC and commits updated ELO data
- Frontend fetches elo-data.json and matches players by name, using BCP
  career event history to disambiguate same-name candidates
- ELO score shown in new sortable Stat-Check ELO column, visible on all
  screen sizes; unmatched players show —

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-29 00:00:02 -05:00
co-authored by Claude Sonnet 4.6
parent 7dd9ddfefe
commit ac223a6909
10 changed files with 445 additions and 6 deletions
+64 -6
View File
@@ -68,10 +68,11 @@ async function fetchCareer(userId) {
`${API}/placings?placingsType=player&userId=${userId}&limit=1000`,
{ headers: HEADERS, credentials: 'omit' }
);
if (!r.ok) return { w: 0, l: 0, t: 0 };
if (!r.ok) return { w: 0, l: 0, t: 0, events: [] };
const p = await r.json();
const items = Array.isArray(p) ? p : (p.data || []);
let w = 0, l = 0, t = 0;
const events = [];
for (const item of items) {
const v = item.value || item;
if (v.wins != null || v.losses != null) {
@@ -79,8 +80,10 @@ async function fetchCareer(userId) {
l += +(v.losses || 0);
t += +(v.ties || 0);
}
const eName = v.event?.name || v.eventName || v.tournament?.name || v.tournamentName || '';
if (eName) events.push(eName);
}
return { w, l, t };
return { w, l, t, events };
}
async function loadCareerStats(kept) {
@@ -93,11 +96,56 @@ async function loadCareerStats(kept) {
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 }; }
catch { careerData[uid] = { w: 0, l: 0, t: 0, events: [] }; }
}));
if (lastState) render(lastState);
}
/* ---- ELO data (stat-check) ---- */
let eloData; // undefined = loading, null = unavailable, Map = loaded
function normalizeEventName(s) {
return String(s).toLowerCase().replace(/[^a-z0-9 ]/g, '').replace(/\s+/g, ' ').trim();
}
function eventNamesMatch(a, b) {
const na = normalizeEventName(a), nb = normalizeEventName(b);
if (!na || !nb || na.length < 6 || nb.length < 6) return na === nb;
return na === nb || na.includes(nb) || nb.includes(na);
}
async function loadEloData() {
try {
const r = await fetch('/elo-data.json');
if (!r.ok) { eloData = null; return; }
const d = await r.json();
const map = new Map();
for (const p of (d.players || [])) {
const key = String(p.name || '').toLowerCase();
if (!key) continue;
if (!map.has(key)) map.set(key, []);
map.get(key).push(p);
}
eloData = map;
if (lastState) render(lastState);
} catch { eloData = null; }
}
// Returns: undefined = still loading, null = no verified match, object = verified match
function findElo(name, uid) {
if (eloData === undefined) return undefined;
if (!eloData) return null;
const candidates = eloData.get(String(name).toLowerCase()) || [];
if (!candidates.length) return null;
if (uid && !(uid in careerData)) return undefined; // career fetch not started yet
const cd = uid ? careerData[uid] : undefined;
if (cd === null) return undefined; // career in-flight
const events = cd?.events || [];
if (!events.length) return candidates.length === 1 ? candidates[0] : null;
const verified = candidates.filter(c => events.some(e => eventNamesMatch(e, c.lastEvent)));
return verified.length === 1 ? verified[0] : null;
}
/* ---- Sort state ---- */
let sortState = { col: 'pts', dir: 'desc' };
let lastState = null;
@@ -107,7 +155,8 @@ const sortCols = {
name: { key: r => fullName(r.user || {}).toLowerCase() || '', defaultDir: 'asc' },
wlt: { key: r => (+r.wins||0) * 10000 - (+r.losses||0) * 100 - (+r.ties||0), defaultDir: 'desc' },
wpct: { key: r => { const g=(+r.wins||0)+(+r.losses||0)+(+r.ties||0); return g?(+r.wins||0)/g:-1; }, defaultDir: 'desc' },
rank: { key: r => +(r.placing || Infinity), defaultDir: 'asc' },
rank: { key: r => +(r.placing || Infinity), defaultDir: 'asc' },
elo: { key: r => { const e = findElo(fullName(r.user || {}), r.userId || r.user?.id); return e?.elo ?? -Infinity; }, defaultDir: 'desc' },
};
function applySort(rows) {
@@ -282,7 +331,14 @@ function render(state) {
`<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>`;
`<td class="text-right font-mono text-base-content/40 hidden sm:table-cell">#${r.placing ?? "—"}</td>` +
(() => {
const uid = r.userId || r.user?.id;
const e = findElo(fullName(r.user || {}), uid);
if (e === undefined) return `<td class="text-center font-mono"><span class="opacity-20">…</span></td>`;
if (!e) return `<td class="text-center font-mono text-base-content/40">—</td>`;
return `<td class="text-center font-mono">${Math.round(e.elo)}</td>`;
})();
tb.appendChild(tr);
});
}
@@ -349,7 +405,8 @@ function showSkeleton() {
`<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>`;
`<td class="hidden sm:table-cell"><div class="skeleton h-4 w-10 ml-auto"></div></td>` +
`<td><div class="skeleton h-4 w-12 mx-auto"></div></td>`;
tb.appendChild(tr);
}
}
@@ -384,4 +441,5 @@ const SAMPLE = (() => {
$("refresh").addEventListener("click", () => load(true));
initSortHeaders();
paintCache();
loadEloData();
load(false);