diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 439857e..7016472 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,7 +1,26 @@ stages: + - sync - build - deploy +# Runs only on scheduled pipelines (every Wednesday via GitLab schedule). +# Downloads the stat-check.com ELO Excel sheet, writes public/elo-data.json, +# then commits and pushes so a normal build+deploy pipeline picks it up. +# Requires CI variable: GITLAB_PUSH_TOKEN (project token, write_repository scope) +sync-elo: + stage: sync + only: + - schedules + script: + - python3 scripts/sync-elo.py + - git config user.email "ci@gateway-gamers.net" + - git config user.name "GG CI Bot" + - git remote set-url origin "https://oauth2:${GITLAB_PUSH_TOKEN}@gl.mando.club/mandalore/40k-rankings.gateway-gamers.net.git" + - git add public/elo-data.json + - git diff --staged --quiet && echo "No ELO changes." || (git commit -m "sync: update ELO data" && git push origin HEAD:main) + +# Build and deploy skip on scheduled pipelines — the robot push above +# triggers its own normal pipeline that handles the actual build + deploy. build: stage: build script: @@ -13,6 +32,8 @@ build: expire_in: 1 hour only: - main + except: + - schedules deploy: stage: deploy @@ -20,6 +41,8 @@ deploy: - rsync -avz --delete dist/ /var/www/domains/gateway-gamers.net/40k-rankings/ only: - main + except: + - schedules environment: name: production url: https://40k-rankings.gateway-gamers.net diff --git a/index.html b/index.html index 8c3a934..3a1b269 100644 --- a/index.html +++ b/index.html @@ -76,6 +76,7 @@ W–L–T Win % ITC Rank + ELO Rank diff --git a/public/elo-data.json b/public/elo-data.json new file mode 100644 index 0000000..f40bbc0 --- /dev/null +++ b/public/elo-data.json @@ -0,0 +1 @@ +{"updated":null,"count":0,"byName":{}} diff --git a/scripts/sync-elo.py b/scripts/sync-elo.py new file mode 100644 index 0000000..8cbbdd8 --- /dev/null +++ b/scripts/sync-elo.py @@ -0,0 +1,114 @@ +""" +sync-elo.py — download the stat-check.com ELO Excel sheet and emit public/elo-data.json. + +Column auto-detection: scans the header row for keywords. +If headers change, check the CI log — it always prints what it found. +""" + +import json +import sys +from datetime import datetime, timezone +from io import BytesIO + +try: + import requests + import openpyxl +except ImportError: + print("Installing dependencies…", flush=True) + import subprocess + subprocess.check_call([sys.executable, "-m", "pip", "install", "requests", "openpyxl", "-q"]) + import requests + import openpyxl + +DOWNLOAD_URL = ( + "https://excel.officeapps.live.com/x/_layouts/XlFileHandler.aspx" + "?WacUserType=WOPI" + "&usid=4bcd36aa-91bc-4c95-a692-c2ef5de1d13a" + "&NoAuth=1" + "&waccluster=PCA1" +) +OUTPUT_PATH = "public/elo-data.json" + + +def find_col(headers, *keywords): + """Return index of first header containing any keyword (case-insensitive).""" + for i, h in enumerate(headers): + hl = str(h).lower() + if any(kw in hl for kw in keywords): + return i + return None + + +def main(): + print(f"Downloading ELO sheet…", flush=True) + resp = requests.get(DOWNLOAD_URL, timeout=120, headers={"User-Agent": "Mozilla/5.0"}) + resp.raise_for_status() + print(f"Downloaded {len(resp.content):,} bytes", flush=True) + + wb = openpyxl.load_workbook(BytesIO(resp.content), read_only=True, data_only=True) + ws = wb.active + + rows = ws.iter_rows(values_only=True) + raw_headers = next(rows) + headers = [str(c).strip() if c is not None else "" for c in raw_headers] + print(f"Headers ({len(headers)}): {headers}", flush=True) + + rank_col = find_col(headers, "rank") + name_col = find_col(headers, "name", "player") + rating_col = find_col(headers, "rating", "elo", "score", "points") + + if name_col is None: + print("ERROR: Could not find a name/player column. Check the headers above.", file=sys.stderr) + sys.exit(1) + + print( + f"Mapped rank→col {rank_col} ('{headers[rank_col] if rank_col is not None else '—'}') " + f"name→col {name_col} ('{headers[name_col]}') " + f"rating→col {rating_col} ('{headers[rating_col] if rating_col is not None else '—'}')", + flush=True, + ) + + by_name = {} + skipped = 0 + for seq, row in enumerate(rows, start=2): + raw_name = row[name_col] if len(row) > name_col else None + if not raw_name: + skipped += 1 + continue + + name = str(raw_name).strip() + key = name.lower() + + raw_rank = row[rank_col] if rank_col is not None and len(row) > rank_col else None + raw_rating = row[rating_col] if rating_col is not None and len(row) > rating_col else None + + try: + rank = int(raw_rank) if raw_rank is not None else seq + except (ValueError, TypeError): + rank = seq + + try: + rating = round(float(raw_rating), 1) if raw_rating is not None else None + except (ValueError, TypeError): + rating = None + + by_name[key] = {"rank": rank, "rating": rating, "name": name} + + wb.close() + + print(f"Parsed {len(by_name):,} players ({skipped} blank rows skipped)", flush=True) + + output = { + "updated": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "count": len(by_name), + "byName": by_name, + } + + with open(OUTPUT_PATH, "w", encoding="utf-8") as f: + json.dump(output, f, separators=(",", ":"), ensure_ascii=False) + + print(f"Wrote {OUTPUT_PATH}", flush=True) + + +if __name__ == "__main__": + main() diff --git a/src/main.js b/src/main.js index a8da12b..a18e63b 100644 --- a/src/main.js +++ b/src/main.js @@ -60,6 +60,34 @@ 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(); +/* ---- ELO data ---- */ +let eloData = null; + +async function loadElo() { + try { + const r = await fetch('/elo-data.json'); + if (!r.ok) return; + const d = await r.json(); + if (d && d.byName && d.count > 0) { + eloData = d; + if (lastState) render(lastState); + } + } catch (e) { /* ELO is optional — fail silently */ } +} + +function lookupElo(r) { + if (!eloData) return null; + const name = fullName(r.user || {}).toLowerCase().trim(); + if (eloData.byName[name]) return eloData.byName[name]; + // Fall back to first + last only (strips middle initials) + const parts = name.split(/\s+/).filter(Boolean); + if (parts.length > 2) { + const short = parts[0] + ' ' + parts[parts.length - 1]; + if (eloData.byName[short]) return eloData.byName[short]; + } + return null; +} + /* ---- Sort state ---- */ let sortState = { col: 'pts', dir: 'desc' }; let lastState = null; @@ -70,6 +98,7 @@ const sortCols = { 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' }, + elo: { key: r => { const e = lookupElo(r); return e ? e.rank : Infinity; }, defaultDir: 'asc' }, }; function applySort(rows) { @@ -223,7 +252,10 @@ function render(state) { `` + `${r.wins || 0}${r.losses || 0}–${r.ties || 0}` + `${fmtWpct(r.wins, r.losses, r.ties)}` + - `#${r.placing ?? "—"}`; + `#${r.placing ?? "—"}` + + (eloData + ? (() => { const e = lookupElo(r); return `${e ? `#${e.rank.toLocaleString()}` : ''}`; })() + : `…`); tb.appendChild(tr); }); } @@ -290,6 +322,7 @@ function showSkeleton() { `
` + `
` + `
` + + `
` + `
`; tb.appendChild(tr); } @@ -326,3 +359,4 @@ $("refresh").addEventListener("click", () => load(true)); initSortHeaders(); paintCache(); load(false); +loadElo();