add weekly ELO rank sync from stat-check.com
- scripts/sync-elo.py: downloads Excel sheet, auto-detects columns, writes public/elo-data.json keyed by lowercase player name - .gitlab-ci.yml: sync-elo stage (schedules only) commits the JSON then a normal push pipeline handles build + deploy; build/deploy skip on scheduled runs to avoid double deployment - index.html + main.js: ELO Rank column with sort support; loads elo-data.json asynchronously and re-renders when ready; fails silently if data is missing Requires one-time setup: 1. GitLab project token (write_repository) stored as GITLAB_PUSH_TOKEN 2. Pipeline schedule: cron 0 2 * * 3 (Wednesday 02:00 UTC) on main Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,26 @@
|
|||||||
stages:
|
stages:
|
||||||
|
- sync
|
||||||
- build
|
- build
|
||||||
- deploy
|
- 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:
|
build:
|
||||||
stage: build
|
stage: build
|
||||||
script:
|
script:
|
||||||
@@ -13,6 +32,8 @@ build:
|
|||||||
expire_in: 1 hour
|
expire_in: 1 hour
|
||||||
only:
|
only:
|
||||||
- main
|
- main
|
||||||
|
except:
|
||||||
|
- schedules
|
||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
stage: deploy
|
stage: deploy
|
||||||
@@ -20,6 +41,8 @@ deploy:
|
|||||||
- rsync -avz --delete dist/ /var/www/domains/gateway-gamers.net/40k-rankings/
|
- rsync -avz --delete dist/ /var/www/domains/gateway-gamers.net/40k-rankings/
|
||||||
only:
|
only:
|
||||||
- main
|
- main
|
||||||
|
except:
|
||||||
|
- schedules
|
||||||
environment:
|
environment:
|
||||||
name: production
|
name: production
|
||||||
url: https://40k-rankings.gateway-gamers.net
|
url: https://40k-rankings.gateway-gamers.net
|
||||||
|
|||||||
@@ -76,6 +76,7 @@
|
|||||||
<th class="text-right hidden sm:table-cell cursor-pointer select-none" data-sort="wlt">W–L–T</th>
|
<th class="text-right hidden sm:table-cell cursor-pointer select-none" data-sort="wlt">W–L–T</th>
|
||||||
<th class="text-right hidden sm:table-cell cursor-pointer select-none" data-sort="wpct">Win %</th>
|
<th class="text-right hidden sm:table-cell cursor-pointer select-none" data-sort="wpct">Win %</th>
|
||||||
<th class="text-right hidden sm:table-cell cursor-pointer select-none" data-sort="rank">ITC Rank</th>
|
<th class="text-right hidden sm:table-cell cursor-pointer select-none" data-sort="rank">ITC Rank</th>
|
||||||
|
<th class="text-right hidden sm:table-cell cursor-pointer select-none" data-sort="elo">ELO Rank</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="rows"></tbody>
|
<tbody id="rows"></tbody>
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
{"updated":null,"count":0,"byName":{}}
|
||||||
@@ -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()
|
||||||
+35
-1
@@ -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 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();
|
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 ---- */
|
/* ---- Sort state ---- */
|
||||||
let sortState = { col: 'pts', dir: 'desc' };
|
let sortState = { col: 'pts', dir: 'desc' };
|
||||||
let lastState = null;
|
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' },
|
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' },
|
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 = lookupElo(r); return e ? e.rank : Infinity; }, defaultDir: 'asc' },
|
||||||
};
|
};
|
||||||
|
|
||||||
function applySort(rows) {
|
function applySort(rows) {
|
||||||
@@ -223,7 +252,10 @@ function render(state) {
|
|||||||
`<td class="text-right font-mono hidden sm:table-cell">` +
|
`<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>` +
|
`<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>` +
|
`<td class="text-right font-mono hidden sm:table-cell">${fmtWpct(r.wins, r.losses, r.ties)}</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>` +
|
||||||
|
(eloData
|
||||||
|
? (() => { const e = lookupElo(r); return `<td class="text-right font-mono hidden sm:table-cell">${e ? `#${e.rank.toLocaleString()}` : '<span class="opacity-30">—</span>'}</td>`; })()
|
||||||
|
: `<td class="text-right font-mono text-base-content/20 hidden sm:table-cell">…</td>`);
|
||||||
tb.appendChild(tr);
|
tb.appendChild(tr);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -290,6 +322,7 @@ function showSkeleton() {
|
|||||||
`<td><div class="skeleton h-4 w-12 ml-auto"></div></td>` +
|
`<td><div class="skeleton h-4 w-12 ml-auto"></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-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-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 class="hidden sm:table-cell"><div class="skeleton h-4 w-10 ml-auto"></div></td>`;
|
||||||
tb.appendChild(tr);
|
tb.appendChild(tr);
|
||||||
}
|
}
|
||||||
@@ -326,3 +359,4 @@ $("refresh").addEventListener("click", () => load(true));
|
|||||||
initSortHeaders();
|
initSortHeaders();
|
||||||
paintCache();
|
paintCache();
|
||||||
load(false);
|
load(false);
|
||||||
|
loadElo();
|
||||||
|
|||||||
Reference in New Issue
Block a user