Import scouting.gateway-gamers.net into monorepo

This commit is contained in:
2026-09-04 14:55:10 -05:00
18 changed files with 3046 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
.env
.env.local
.claude/settings.local.json
+29
View File
@@ -0,0 +1,29 @@
stages:
- build
- deploy
build:
stage: build
script:
- npm ci
- npm run build
artifacts:
paths:
- dist/
expire_in: 1 hour
only:
- main
except:
- schedules
deploy:
stage: deploy
script:
- rsync -avz --delete dist/ /var/www/domains/gateway-gamers.net/scouting/
only:
- main
except:
- schedules
environment:
name: production
url: https://scouting.gateway-gamers.net
+172
View File
@@ -0,0 +1,172 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Gateway Gamers — Scouting</title>
<link rel="stylesheet" href="/src/style.css" />
</head>
<body class="min-h-screen flex flex-col">
<div id="header"></div>
<main class="flex-1 max-w-5xl w-full mx-auto px-6 py-10">
<!-- Page heading -->
<div class="mb-6">
<h1 class="text-4xl font-bold uppercase tracking-wide">BCP Roster Scout</h1>
<p class="text-sm opacity-50 uppercase tracking-widest mt-1">Paste a Best Coast Pairings event link for instant roster stats</p>
</div>
<!-- Input row -->
<form id="scout-form" class="flex gap-2 mb-8">
<input
id="event-input"
type="text"
placeholder="https://www.bestcoastpairings.com/event/… or just the event ID"
class="input input-bordered flex-1 font-mono text-sm"
autocomplete="off"
/>
<button id="scout-btn" type="submit" class="btn btn-primary uppercase tracking-wide">Scout</button>
</form>
<!-- Optional: BCP auth token for backfilling missing faction/disposition from submitted lists -->
<details class="mb-6 bg-base-200 rounded-box">
<summary class="cursor-pointer px-4 py-2 text-sm opacity-70 select-none">
Have a BCP subscription? Add your auth token to backfill missing faction/disposition data
</summary>
<div class="px-4 pb-2 flex gap-2 items-center flex-wrap">
<input
id="bcp-token-input"
type="password"
placeholder="Paste your BCP auth token…"
class="input input-bordered input-sm flex-1 font-mono"
autocomplete="off"
/>
<button id="bcp-token-save" type="button" class="btn btn-sm">Save</button>
<button id="bcp-token-clear" type="button" class="btn btn-sm btn-ghost">Clear</button>
</div>
<p class="px-4 pb-1 text-xs opacity-40">
Stored only in your browser (localStorage) — sent only to BCP's own API, never committed or deployed.
Tokens expire after about an hour, so you may need to paste a fresh one occasionally.
</p>
<p class="px-4 pb-3 text-xs" id="bcp-token-status"></p>
</details>
<!-- Banner (warnings / errors) -->
<div class="alert mb-6 hidden" id="banner"></div>
<!-- Loading skeleton -->
<div class="hidden space-y-4" id="loading">
<div class="skeleton h-8 w-2/3"></div>
<div class="skeleton h-4 w-1/3"></div>
<div class="skeleton h-24 w-full"></div>
<div class="skeleton h-48 w-full"></div>
</div>
<!-- Empty state -->
<div class="text-center text-base-content/50 py-16" id="empty">
Paste a BCP event link above to see roster stats — faction spread, dispositions, checked-in rate, and team breakdowns.
</div>
<!-- Results -->
<div class="hidden" id="results">
<!-- Event header -->
<div class="card card-border bg-base-200 mb-6">
<div class="card-body py-5">
<div class="flex items-start justify-between gap-4 flex-wrap">
<div>
<h2 class="text-2xl font-bold" id="ev-name"></h2>
<p class="text-sm opacity-60 mt-1" id="ev-sub"></p>
</div>
<div class="badge badge-lg" id="ev-status"></div>
</div>
</div>
</div>
<!-- Stats strip -->
<div class="stats shadow w-full mb-6 bg-base-200 stats-vertical sm:stats-horizontal">
<div class="stat">
<div class="stat-title">Players</div>
<div class="stat-value text-2xl" id="s-players"></div>
</div>
<div class="stat" id="s-teams-wrap">
<div class="stat-title">Teams</div>
<div class="stat-value text-2xl" id="s-teams"></div>
</div>
<div class="stat">
<div class="stat-title">Checked In</div>
<div class="stat-value text-2xl" id="s-checkin"></div>
</div>
<div class="stat">
<div class="stat-title">Lists Submitted</div>
<div class="stat-value text-2xl" id="s-lists"></div>
</div>
</div>
<!-- Insights -->
<div class="mb-8">
<h3 class="text-lg font-bold uppercase tracking-wide mb-3">Insights</h3>
<ul class="space-y-2" id="insights"></ul>
</div>
<!-- Faction distribution -->
<div class="mb-8">
<h3 class="text-lg font-bold uppercase tracking-wide mb-3">Faction Distribution</h3>
<div id="faction-bars"></div>
</div>
<!-- Disposition distribution (across all factions) -->
<div class="mb-8">
<div class="flex items-center justify-between mb-3">
<h3 class="text-lg font-bold uppercase tracking-wide">Top Dispositions</h3>
<button id="disposition-toggle" class="btn btn-ghost btn-xs">Show all</button>
</div>
<div id="disposition-bars"></div>
</div>
<!-- Roster: per-team dropdowns for team events, flat table for individual events -->
<div class="mb-8">
<div class="flex items-center justify-between mb-3 flex-wrap gap-2">
<h3 class="text-lg font-bold uppercase tracking-wide">Roster</h3>
<input id="player-filter" type="text" placeholder="Filter by name, team, or faction…" class="input input-bordered input-sm w-full sm:w-72" />
</div>
<!-- Team accordion (team events) -->
<div class="space-y-2 hidden" id="team-list"></div>
<!-- Flat table (individual events) -->
<div class="overflow-x-auto hidden" id="player-table-wrap">
<table class="table table-zebra w-full">
<thead>
<tr>
<th>Player</th>
<th>Faction</th>
<th class="hidden sm:table-cell">Disposition</th>
<th class="text-right">ITC Rank</th>
<th class="text-right">Career W%</th>
<th class="text-right">List</th>
</tr>
</thead>
<tbody id="player-rows"></tbody>
</table>
</div>
<p class="text-xs text-base-content/40 mt-2" id="player-count"></p>
</div>
</div>
<div class="divider mt-8"></div>
<p class="text-xs text-base-content/40 text-center">
Data from Best Coast Pairings · roster-only (this tool doesn't yet read pairings/placings)
</p>
</main>
<div id="footer"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+1868
View File
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
{
"name": "scouting-gateway-gamers",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.0.0",
"daisyui": "^5.0.0",
"tailwindcss": "^4.0.0",
"vite": "^6.0.0"
}
}
+5
View File
@@ -0,0 +1,5 @@
export default {
plugins: {
'@tailwindcss/postcss': {}
}
}
+3
View File
@@ -0,0 +1,3 @@
<footer class="footer footer-center bg-base-300 text-base-content p-4">
<p>&copy; 2026 Gateway Gamers. All rights reserved.</p>
</footer>
+57
View File
@@ -0,0 +1,57 @@
<header class="bg-base-300 shadow-lg">
<!-- Hero: carousel background + centered logo -->
<div class="relative overflow-hidden min-h-64 py-10 flex items-center justify-center">
<!-- Carousel images -->
<div id="hero-carousel" class="absolute inset-0">
<img src="/images/photo1.jpg" class="carousel-slide absolute inset-0 w-full h-full object-cover opacity-0 transition-opacity duration-1000" />
<img src="/images/photo2.jpg" class="carousel-slide absolute inset-0 w-full h-full object-cover opacity-0 transition-opacity duration-1000" />
<img src="/images/photo3.jpg" class="carousel-slide absolute inset-0 w-full h-full object-cover opacity-0 transition-opacity duration-1000" />
<img src="/images/photo4.jpg" class="carousel-slide absolute inset-0 w-full h-full object-cover opacity-0 transition-opacity duration-1000" />
<img src="/images/photo5.jpg" class="carousel-slide absolute inset-0 w-full h-full object-cover opacity-0 transition-opacity duration-1000" />
</div>
<!-- Overlay so logo stays readable over busy images -->
<div class="absolute inset-0 bg-black/40"></div>
<!-- Logo -->
<div class="relative z-10">
<a href="/index.html">
<img src="/images/logo.png" alt="Gateway Gamers" class="h-48 w-auto drop-shadow-2xl" />
</a>
</div>
</div>
<!-- Nav row -->
<div class="navbar bg-base-200 px-4 min-h-0 py-2">
<div class="navbar-center hidden lg:flex flex-1 justify-center">
<ul class="menu menu-horizontal px-1 gap-1">
<li><a href="/index.html">Scouting</a></li>
</ul>
</div>
<div class="navbar-end gap-2 ml-auto">
<label class="swap swap-rotate btn btn-ghost btn-circle" title="Toggle light/dark">
<input type="checkbox" class="theme-controller" value="light" />
<svg class="swap-off h-5 w-5 fill-current" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M21.64 13a1 1 0 0 0-1.27-.66 8.5 8.5 0 0 1-10.71-10.71 1 1 0 0 0-1.32-1.27A10 10 0 1 0 22 14.29a1 1 0 0 0-.36-1.29z"/>
</svg>
<svg class="swap-on h-5 w-5 fill-current" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M5.64 17l-.71.71a1 1 0 0 0 1.41 1.41l.71-.71A1 1 0 0 0 5.64 17zM5 12a1 1 0 0 0-1-1H3a1 1 0 0 0 0 2h1a1 1 0 0 0 1-1zm7-7a1 1 0 0 0 1-1V3a1 1 0 0 0-2 0v1a1 1 0 0 0 1 1zM5.64 7.05a1 1 0 0 0 .7.29 1 1 0 0 0 .71-.29 1 1 0 0 0 0-1.41l-.71-.71a1 1 0 0 0-1.41 1.41zm12 .29a1 1 0 0 0 .7-.29l.71-.71a1 1 0 0 0-1.41-1.41l-.71.71a1 1 0 0 0 0 1.41 1 1 0 0 0 .71.29zM21 11h-1a1 1 0 0 0 0 2h1a1 1 0 0 0 0-2zm-9 8a1 1 0 0 0-1 1v1a1 1 0 0 0 2 0v-1a1 1 0 0 0-1-1zm6.36-2a1 1 0 0 0-1.41 1.41l.71.71a1 1 0 0 0 1.41-1.41zM12 6.5a5.5 5.5 0 1 0 5.5 5.5A5.51 5.51 0 0 0 12 6.5z"/>
</svg>
</label>
<div class="dropdown dropdown-end lg:hidden">
<label tabindex="0" class="btn btn-ghost btn-circle">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</label>
<ul tabindex="0" class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow bg-base-200 rounded-box w-52">
<li><a href="/index.html">Scouting</a></li>
</ul>
</div>
</div>
</div>
</header>
Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 MiB

@@ -0,0 +1,41 @@
server {
listen 80;
listen [::]:80;
server_name scouting.gateway-gamers.net;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name scouting.gateway-gamers.net;
root /var/www/domains/gateway-gamers.net/scouting;
index index.html;
# SSL — managed by Certbot
# ssl_certificate /etc/letsencrypt/live/scouting.gateway-gamers.net/fullchain.pem;
# ssl_certificate_key /etc/letsencrypt/live/scouting.gateway-gamers.net/privkey.pem;
# include /etc/letsencrypt/options-ssl-nginx.conf;
# ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
# Hide nginx version
server_tokens off;
# Security headers
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
# style-src needs 'unsafe-inline': the app sets bar-chart widths via
# inline style="width:X%" through innerHTML, which CSP's style-src
# blocks without it (script-src stays locked down — this only affects
# CSS, and nothing here renders untrusted external content into a
# style attribute).
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' https://newprod-api.bestcoastpairings.com; img-src 'self' data:;" always;
location / {
try_files $uri $uri/ =404;
}
}
+822
View File
@@ -0,0 +1,822 @@
/* ---- Shared: header / footer ---- */
function loadHTML(file, elementId, callback) {
fetch(file)
.then(r => r.text())
.then(html => {
document.getElementById(elementId).innerHTML = html;
if (callback) callback();
})
.catch(err => console.error('Error loading:', file, err));
}
function highlightActiveNav() {
const current = location.pathname.split('/').pop() || 'index.html';
document.querySelectorAll('#header a[href]').forEach(link => {
if (link.getAttribute('href') === `/${current}`) {
link.classList.add('active');
}
});
}
function startCarousel() {
const slides = document.querySelectorAll('#hero-carousel .carousel-slide');
if (!slides.length) return;
let current = 0;
slides[current].style.opacity = '1';
setInterval(() => {
slides[current].style.opacity = '0';
current = (current + 1) % slides.length;
slides[current].style.opacity = '1';
}, 5000);
}
loadHTML('/header.html', 'header', () => {
highlightActiveNav();
startCarousel();
});
loadHTML('/footer.html', 'footer');
/* ---- BCP API ---- */
const API = "https://newprod-api.bestcoastpairings.com/v1";
const HEADERS = { "client-id": "web-app", "env": "bcp", "accept": "application/json" };
const BCP_ORIGIN = "https://www.bestcoastpairings.com";
async function fetchJSON(url, extraHeaders) {
const r = await fetch(url, { headers: { ...HEADERS, ...extraHeaders }, credentials: 'omit' });
if (!r.ok) throw new Error(`http/${r.status}`);
return r.json();
}
const fetchEvent = id => fetchJSON(`${API}/events/${id}`);
const fetchRoster = async id => (await fetchJSON(`${API}/events/${id}/players`)).active || [];
const fetchTeamplayers = async id => (await fetchJSON(`${API}/events/${id}/teamplayers`)).active || [];
// Requires the viewer's own BCP auth token (subscriber-only endpoint).
const fetchArmyList = (listId, token) => fetchJSON(`${API}/armylists/${listId}`, { authorization: `Bearer ${token}` });
// "ITC Rank" = a player's placing in BCP league "2026 Warhammer 40,000 ITC"
// (leagueId BYaaUfKum7z0, US region) — the national ITC leaderboard, same
// source the rankings site uses for its own ITC Rank column.
const ITC_LEAGUE_ID = "BYaaUfKum7z0";
const ITC_REGION_ID = "VgQKgqmTPU";
// Cursor-paginated league placings (BCP returns the same cursor when there
// are no more results — stop when it loops).
async function* streamPlacings(leagueId, regionId) {
let base = `${API}/placings?placingsType=player&leagueId=${leagueId}&sortAscending=false&limit=1000`;
if (regionId) base += `&regionId=${regionId}`;
let nextKey = null;
let prevNextKey = undefined;
do {
const url = nextKey ? `${base}&nextKey=${encodeURIComponent(nextKey)}` : base;
const r = await fetch(url, { headers: HEADERS, credentials: "omit" });
if (!r.ok) throw new Error("placings/" + r.status);
const p = await r.json();
const page = Array.isArray(p) ? p : (p.data || p.placings || []);
yield page;
const rawKey = p.nextKey;
nextKey = !rawKey ? null
: typeof rawKey === "string" ? rawKey
: btoa(JSON.stringify(rawKey));
if (nextKey === prevNextKey) break;
prevNextKey = 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) {
const ranks = new Map();
if (!userIds.size) return ranks;
try {
for await (const page of streamPlacings(ITC_LEAGUE_ID, ITC_REGION_ID)) {
for (const rec of page) {
const uid = rec.userId || rec.user?.id;
if (uid && userIds.has(uid) && rec.placing != null && !ranks.has(uid)) {
ranks.set(uid, rec.placing);
}
}
if (ranks.size >= userIds.size) break; // found everyone we need
}
} catch (e) {
console.warn("ITC rank load failed:", e);
}
return ranks;
}
/* ---- BCP auth token (subscriber-only, for the list backfill below) ----
Stored only in this browser's localStorage. Sent only in requests this
browser makes directly to BCP's own API — never to any other server,
never committed, never part of the deployed bundle. */
const TOKEN_KEY = "bcp_auth_token";
const getToken = () => { try { return localStorage.getItem(TOKEN_KEY) || ""; } catch { return ""; } };
const setToken = t => { try { t ? localStorage.setItem(TOKEN_KEY, t) : localStorage.removeItem(TOKEN_KEY); } catch {} };
// This event tracks the five ITC-standard mission dispositions. Hand-typed
// sources (a "Dispositions Used:" line in a submitted list, BCP's own free
// text) routinely have small typos (e.g. "Priority Assests"), so every
// disposition value is fuzzy-matched against this fixed list before being
// counted or displayed, keeping typo'd variants from forking off as their
// own duplicate bar.
const CANONICAL_DISPOSITIONS = ["Purge the Foe", "Take and Hold", "Priority Assets", "Disruption", "Reconnaissance"];
function levenshtein(a, b) {
const m = a.length, n = b.length;
if (!m) return n;
if (!n) return m;
let prev = Array.from({ length: n + 1 }, (_, i) => i);
for (let i = 1; i <= m; i++) {
const cur = [i];
for (let j = 1; j <= n; j++) {
cur[j] = a[i - 1] === b[j - 1] ? prev[j - 1] : 1 + Math.min(prev[j - 1], prev[j], cur[j - 1]);
}
prev = cur;
}
return prev[n];
}
const normalizeDispositionText = s => String(s || "").trim().toLowerCase().replace(/[^a-z ]/g, "").replace(/\s+/g, " ");
// Filler-word-stripped form of each canonical name, so abbreviations that
// drop "and"/"the" (e.g. "Take Hold") still line up: "Take and Hold" -> "take hold".
const dispositionCore = canonical => normalizeDispositionText(canonical).replace(/\b(and|the)\b/g, "").replace(/\s+/g, " ").trim();
// Nearest canonical disposition for a single (already-normalized) segment, or
// null if nothing is close enough to be confident it's a typo/abbreviation
// rather than unrelated text. Two ways to match:
// - Prefix: shorthand like "Recon" or "Disrupt" that's a truncation, not a
// typo, so Levenshtein distance alone would score it as far away. Gated
// to 3+ chars so short unrelated words can't collide (the five names'
// first three letters — pur/tak/pri/dis/rec — are all distinct).
// - Levenshtein: everything else, e.g. "Priority Assests". Threshold scales
// with name length (~25%) so a short unrelated line doesn't accidentally
// match "Disruption".
function matchDispositionSegment(norm) {
if (!norm) return null;
let best = null, bestDist = Infinity;
for (const canonical of CANONICAL_DISPOSITIONS) {
const full = canonical.toLowerCase();
const core = dispositionCore(canonical);
if (norm.length >= 3 && (full.startsWith(norm) || core.startsWith(norm))) return canonical;
const dist = Math.min(levenshtein(norm, full), levenshtein(norm, core));
if (dist < bestDist) { bestDist = dist; best = canonical; }
}
const threshold = Math.max(1, Math.round(best.length * 0.25));
return bestDist <= threshold ? best : null;
}
// Some sources list two dispositions together, e.g. "Purge the Foe
// (Reconnaissance)" (a team template noting a secondary/alternate) or
// "Take and Hold / Priority Assets". Split on the separators that join them
// and match segment-by-segment left to right, so whichever is listed first
// wins rather than whichever happens to score closer across the whole string.
function closestDisposition(name) {
const raw = String(name || "");
const segments = raw.split(/[()\/|,;]+|\s+vs\.?\s+/i).map(s => s.trim()).filter(Boolean);
for (const seg of segments.length ? segments : [raw]) {
const match = matchDispositionSegment(normalizeDispositionText(seg));
if (match) return match;
}
return null;
}
const correctDisposition = name => closestDisposition(name) || name;
// The vocabulary of disposition names is whatever this event's own roster
// already shows (e.g. "Take and Hold", "Priority Assets", …) — deriving it
// from the event rather than hardcoding keeps this working for other game
// systems/mission packs with different disposition names. Keyed by lowercase
// so matches are case-insensitive (a list's free text may not match the
// roster's casing), with the value holding the roster's canonical casing so
// backfilled entries merge into the same bar instead of forking a duplicate.
function knownDispositions(roster) {
const map = new Map();
for (const r of roster) {
const name = r.subFaction?.name;
if (name && !map.has(name.toLowerCase())) map.set(name.toLowerCase(), name);
}
return map;
}
// A submitted list's faction is a clean field (`army.name`). Its disposition
// isn't — `warhammer.detachment` is the in-game GW detachment name (e.g.
// "Wrath of the Rock"), not BCP's ITC disposition bucket. The disposition
// only appears as a plain line in the exported list text, so we scan for a
// line that exactly matches one of this event's known disposition names.
function extractFactionAndDisposition(armyList, knownDisp) {
const text = String(armyList.armyListText || "");
// Clean structured field — present when BCP resolved the list to a
// canonical army entity (not every list gets one; some submission
// templates skip it entirely, e.g. narrative/story-post formats).
let faction = armyList.army?.name || armyList.player?.army?.name || null;
// Fallback: some team templates include an explicit "Factions Used: X"
// label line in the free text even when the structured field is absent.
if (!faction) {
const m = text.match(/^Factions?\s+Used:\s*(.+)$/im);
if (m) faction = m[1].trim();
}
// Fallback: BCP's own auto-generated list block (the second half of the
// text, distinct from whatever custom template a team prepends) always
// has the faction name on its own line immediately before the line
// naming the detachment, e.g. "…\nEmperor's Children\nCarnival of Excess
// and Frenzied Host (3 Detachment Points)\n…". This anchor holds across
// every template we've seen, unlike any specific label text.
if (!faction) {
const lines = text.split("\n").map(l => l.trim());
const detachmentLineIdx = lines.findIndex(l => /Detachment Points\)/i.test(l));
for (let i = detachmentLineIdx - 1; i >= 0; i--) {
if (lines[i]) { faction = lines[i]; break; }
}
}
// Disposition has no clean structured field at all (BCP's `warhammer`
// object only carries the in-game GW detachment name, e.g. "Wrath of the
// Rock" — not the ITC disposition bucket). Try the explicit label first,
// then fall back to scanning for a standalone line matching this event's
// known disposition vocabulary (BCP's generated list block always
// includes one, right after the detachment line).
let disposition = null;
const dm = text.match(/^Dispositions?\s+Used:\s*(.+)$/im);
if (dm) {
const captured = dm[1].trim();
disposition = knownDisp.get(captured.toLowerCase()) || closestDisposition(captured) || captured;
}
if (!disposition) {
for (const rawLine of text.split("\n")) {
const line = rawLine.trim();
if (!line) continue;
const canonical = knownDisp.get(line.toLowerCase()) || closestDisposition(line);
if (canonical) { disposition = canonical; break; }
}
}
return { faction, disposition };
}
async function backfillFromLists(roster, token) {
const knownDisp = knownDispositions(roster);
const targets = roster.filter(r => r.listId && (!r.faction?.name || !r.subFaction?.name));
const backfill = new Map(); // roster row id -> { faction, disposition }
if (!targets.length) return { backfill, tokenInvalid: false, attempted: 0 };
// Sequential, not parallel: this is a subscriber-gated endpoint, and firing
// many requests at once risks rate-limiting some of them silently. Each
// failure/miss is logged so it's diagnosable from the browser console.
let tokenInvalid = false;
for (const r of targets) {
if (tokenInvalid) break;
const label = fullName(r.user || {}) || r.id;
try {
const list = await fetchArmyList(r.listId, token);
const { faction, disposition } = extractFactionAndDisposition(list, knownDisp);
if (faction || disposition) {
backfill.set(r.id, { faction, disposition });
} else {
console.warn(`[backfill] ${label}: list fetched but no faction/disposition found in it`, list);
}
} catch (e) {
const msg = String(e.message || e);
if (msg.includes("401") || msg.includes("403")) {
tokenInvalid = true;
console.warn(`[backfill] ${label}: auth failed (${msg}) — stopping remaining lookups`);
} else {
console.warn(`[backfill] ${label}: fetch failed (${msg})`);
}
}
}
return { backfill, tokenInvalid, attempted: targets.length };
}
// Non-destructive: only fills fields that were actually missing, and tags
// what it touched so the UI can mark it as inferred.
function applyBackfill(roster, backfill) {
if (!backfill || !backfill.size) return roster;
return roster.map(r => {
const b = backfill.get(r.id);
if (!b) return r;
const patch = {};
if (b.faction && !r.faction?.name) { patch.faction = { name: b.faction }; patch._backfilledFaction = true; }
if (b.disposition && !r.subFaction?.name) { patch.subFaction = { name: b.disposition }; patch._backfilledDisposition = true; }
return Object.keys(patch).length ? { ...r, ...patch } : r;
});
}
/* ---- Helpers ---- */
const $ = id => document.getElementById(id);
const esc = s => String(s).replace(/[&<>"]/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[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();
if (!s) return null;
const m = s.match(/\/event\/([A-Za-z0-9]+)/);
if (m) return m[1];
const bare = s.split("?")[0].split("#")[0].trim();
return /^[A-Za-z0-9]+$/.test(bare) ? bare : null;
}
function fmtDateRange(ev) {
const start = ev.eventDate ? new Date(ev.eventDate) : null;
const end = ev.eventEndDate ? new Date(ev.eventEndDate) : null;
const opts = { month: "short", day: "numeric", year: "numeric" };
if (!start) return "";
if (end && end.toDateString() !== start.toDateString()) {
return `${start.toLocaleDateString(undefined, opts)} ${end.toLocaleDateString(undefined, opts)}`;
}
return start.toLocaleDateString(undefined, opts);
}
/* ---- Stat computation (pure functions over the roster) ---- */
function computeCounts(roster, keyFn) {
const m = new Map();
for (const r of roster) {
const key = keyFn(r) || "Unknown";
m.set(key, (m.get(key) || 0) + 1);
}
return [...m.entries()].map(([name, count]) => ({ name, count })).sort((a, b) => b.count - a.count);
}
const computeFactionCounts = roster => computeCounts(roster, r => r.faction?.name);
// Dispositions are counted across all factions together (e.g. "Take and Hold"
// combines every faction running that disposition), not split per-faction.
const computeDispositionCounts = roster => computeCounts(roster, r => r.subFaction?.name);
// Per-faction disposition breakdown, for the drill-down under each faction bar.
function computeDispositionsByFaction(roster) {
const byFaction = new Map();
for (const r of roster) {
const faction = r.faction?.name || "Unknown";
if (!byFaction.has(faction)) byFaction.set(faction, []);
byFaction.get(faction).push(r);
}
const result = new Map();
for (const [faction, players] of byFaction) {
result.set(faction, computeDispositionCounts(players));
}
return result;
}
// Players link to their event-team via teamPlayerId → teamplayers[].id
// (the free-text team name embedded on the player record can be a typo'd
// variant a player typed themselves, so it isn't a safe grouping key).
function computeTeamRollups(roster, teamplayers) {
const byTeamPlayerId = new Map();
for (const r of roster) {
const key = r.teamPlayerId;
if (!key) continue;
if (!byTeamPlayerId.has(key)) byTeamPlayerId.set(key, []);
byTeamPlayerId.get(key).push(r);
}
return teamplayers.map(tp => {
const members = byTeamPlayerId.get(tp.id) || [];
const withList = members.filter(m => m.listUrl).length;
return {
id: tp.id,
name: tp.name || "Unnamed Team",
captain: fullName(tp.captain || {}) || "—",
size: members.length,
withList,
members,
};
}).sort((a, b) => a.name.localeCompare(b.name));
}
// Top/average/bottom ITC rank for a team, computed only from members who
// have a resolved rank (lower placing number = better).
function computeItcStats(members, itcRanks) {
const ranks = members
.map(m => itcRanks.get(m.userId || m.user?.id))
.filter(r => r != null);
if (!ranks.length) return null;
return {
top: Math.min(...ranks),
bottom: Math.max(...ranks),
avg: Math.round(ranks.reduce((s, r) => s + r, 0) / ranks.length),
};
}
function computeInsights(roster, teams, isTeamEvent) {
const insights = [];
const total = roster.length;
if (!total) return insights;
const factionCounts = computeFactionCounts(roster);
if (factionCounts.length) {
const top = factionCounts[0];
insights.push(`<strong>${esc(top.name)}</strong> is the most popular faction — ${top.count} of ${total} players (${pct(top.count, total)}).`);
}
const withList = roster.filter(r => r.listUrl).length;
insights.push(`${withList} of ${total} players (${pct(withList, total)}) have a submitted list.`);
const checkedIn = roster.filter(r => r.checkedIn).length;
if (checkedIn > 0) {
insights.push(`${checkedIn} of ${total} players (${pct(checkedIn, total)}) are checked in.`);
}
if (isTeamEvent && teams.length) {
const withPlayers = teams.filter(t => t.size > 0);
const fullList = withPlayers.filter(t => t.withList === t.size);
const noList = withPlayers.filter(t => t.withList === 0);
if (fullList.length) {
insights.push(`${fullList.length} team${fullList.length === 1 ? "" : "s"} ${fullList.length === 1 ? "has" : "have"} every player's list submitted.`);
}
if (noList.length) {
insights.push(`${noList.length} team${noList.length === 1 ? "" : "s"} ${noList.length === 1 ? "hasn't" : "haven't"} submitted any lists yet.`);
}
}
return insights.slice(0, 4);
}
/* ---- Rendering ---- */
function renderBars(container, items, limit) {
const total = items.reduce((s, it) => s + it.count, 0);
const shown = limit ? items.slice(0, limit) : items;
if (!shown.length) {
container.innerHTML = `<p class="text-sm opacity-50">No data.</p>`;
return;
}
container.innerHTML = shown.map(it => {
const w = total ? Math.max(it.count / total * 100, 2) : 0;
return `
<div class="flex items-center gap-3 py-1">
<div class="w-36 sm:w-48 shrink-0 truncate text-sm" title="${esc(it.name)}">${esc(it.name)}</div>
<div class="flex-1 h-3 rounded-full bg-base-300/60 overflow-hidden">
<div class="viz-bar h-full" style="width:${w}%"></div>
</div>
<div class="w-20 shrink-0 text-right font-mono text-xs tabular-nums opacity-70">${it.count} · ${pct(it.count, total)}</div>
</div>`;
}).join("");
}
// Faction bars, each expandable to show that faction's own disposition breakdown.
function renderFactionBreakdown(container, factionCounts, dispositionsByFaction) {
const total = factionCounts.reduce((s, it) => s + it.count, 0);
if (!factionCounts.length) {
container.innerHTML = `<p class="text-sm opacity-50">No data.</p>`;
return;
}
container.innerHTML = factionCounts.map(it => {
const w = total ? Math.max(it.count / total * 100, 2) : 0;
const subItems = dispositionsByFaction.get(it.name) || [];
const subTotal = subItems.reduce((s, x) => s + x.count, 0);
const subRows = subItems.map(sub => {
const sw = subTotal ? Math.max(sub.count / subTotal * 100, 2) : 0;
return `
<div class="flex items-center gap-3 py-1">
<div class="w-32 sm:w-44 shrink-0 truncate text-xs opacity-80" title="${esc(sub.name)}">${esc(sub.name)}</div>
<div class="flex-1 h-2 rounded-full bg-base-300/40 overflow-hidden">
<div class="viz-bar h-full" style="width:${sw}%"></div>
</div>
<div class="w-16 shrink-0 text-right font-mono text-xs tabular-nums opacity-60">${sub.count} · ${pct(sub.count, subTotal)}</div>
</div>`;
}).join("");
return `
<div class="collapse collapse-arrow bg-base-200/60 border border-base-300 rounded-box">
<input type="checkbox" />
<div class="collapse-title">
<div class="flex items-center gap-3 pr-6">
<div class="w-32 sm:w-44 shrink-0 truncate text-sm font-semibold" title="${esc(it.name)}">${esc(it.name)}</div>
<div class="flex-1 h-3 rounded-full bg-base-300/60 overflow-hidden">
<div class="viz-bar h-full" style="width:${w}%"></div>
</div>
<div class="w-20 shrink-0 text-right font-mono text-xs tabular-nums opacity-70">${it.count} · ${pct(it.count, total)}</div>
</div>
</div>
<div class="collapse-content">
<div class="space-y-1 pt-1">${subRows}</div>
</div>
</div>`;
}).join("");
}
function playerRowHTML(r, itcRanks, itcLoading, careerRecords, careerLoading) {
const name = fullName(r.user || {}) || r.user?.nickname || "Unknown";
const listCell = r.listUrl
? `<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>`;
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 => `<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 dispositionCell = r._backfilledDisposition ? inferred(esc(r.subFaction?.name || "—")) : esc(r.subFaction?.name || "—");
return `
<tr>
<td class="font-semibold">${esc(name)}</td>
<td class="text-sm">${factionCell}</td>
<td class="text-sm opacity-70 hidden sm:table-cell">${dispositionCell}</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>
</tr>`;
}
function memberMatches(m, teamName, q) {
const hay = [fullName(m.user || {}), m.user?.nickname, teamName, m.faction?.name, m.subFaction?.name]
.filter(Boolean).join(" ").toLowerCase();
return hay.includes(q);
}
function renderTeamList(teams, itcRanks, itcLoading, careerRecords, careerLoading, filterText) {
const q = (filterText || "").trim().toLowerCase();
const blocks = teams.map(t => {
const members = t.members.slice().sort((a, b) => fullName(a.user || {}).localeCompare(fullName(b.user || {})));
// Matching the team name pulls in every member, since each member's
// haystack already includes the team name.
const shown = q ? members.filter(m => memberMatches(m, t.name, q)) : members;
if (q && !shown.length) return "";
const stats = computeItcStats(t.members, itcRanks);
const fmtRank = v => (itcLoading ? "…" : v == null ? "—" : `#${v}`);
return `
<div class="collapse collapse-arrow bg-base-200 border border-base-300 rounded-box">
<input type="checkbox" ${q ? "checked" : ""} />
<div class="collapse-title font-semibold">
<div class="flex items-center justify-between gap-4 flex-wrap pr-6">
<div>
<div>${esc(t.name)}</div>
<div class="text-xs opacity-60 font-normal">Captain: ${esc(t.captain)} · ${t.size} players</div>
</div>
<div class="flex gap-4 text-right font-mono text-xs shrink-0">
<div><div class="opacity-50 font-normal uppercase tracking-wide">Top</div>${fmtRank(stats?.top)}</div>
<div><div class="opacity-50 font-normal uppercase tracking-wide">Avg</div>${fmtRank(stats?.avg)}</div>
<div><div class="opacity-50 font-normal uppercase tracking-wide">Bottom</div>${fmtRank(stats?.bottom)}</div>
</div>
</div>
</div>
<div class="collapse-content">
<div class="overflow-x-auto">
<table class="table table-zebra table-sm w-full">
<thead>
<tr>
<th>Player</th>
<th>Faction</th>
<th class="hidden sm:table-cell">Disposition</th>
<th class="text-right">ITC Rank</th>
<th class="text-right">Career W%</th>
<th class="text-right">List</th>
</tr>
</thead>
<tbody>${shown.map(m => playerRowHTML(m, itcRanks, itcLoading, careerRecords, careerLoading)).join("")}</tbody>
</table>
</div>
</div>
</div>`;
}).filter(Boolean);
$("team-list").innerHTML = blocks.join("") ||
`<p class="text-sm opacity-50">No teams match${q ? ` "${esc(filterText)}"` : ""}.</p>`;
}
function renderPlayers(roster, itcRanks, itcLoading, careerRecords, careerLoading, filterText) {
const q = (filterText || "").trim().toLowerCase();
const rows = roster.filter(r => {
if (!q) return true;
const hay = [fullName(r.user || {}), r.user?.nickname, r.faction?.name, r.subFaction?.name]
.filter(Boolean).join(" ").toLowerCase();
return hay.includes(q);
}).sort((a, b) => fullName(a.user || {}).localeCompare(fullName(b.user || {})));
$("player-rows").innerHTML = rows.map(r => playerRowHTML(r, itcRanks, itcLoading, careerRecords, careerLoading)).join("");
$("player-count").textContent = q
? `Showing ${rows.length} of ${roster.length} players`
: `${roster.length} players`;
}
/* ---- Orchestration ---- */
let state = null; // { event, roster, teamplayers, isTeamEvent, itcRanks, itcLoading, backfill, backfillStatus }
let dispositionExpanded = false;
function showError(msg) {
$("loading").classList.add("hidden");
$("results").classList.add("hidden");
$("empty").classList.remove("hidden");
const b = $("banner");
b.className = "alert alert-error mb-6";
b.innerHTML = esc(msg);
}
function clearBanner() {
const b = $("banner");
b.className = "alert mb-6 hidden";
b.innerHTML = "";
}
function render() {
if (!state) return;
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) } }
: r
);
const teams = isTeamEvent ? computeTeamRollups(roster, teamplayers) : [];
const tokenStatusEl = $("bcp-token-status");
if (tokenStatusEl) {
let text = "";
if (backfillStatus === "loading") text = "Backfilling missing data from submitted lists…";
else if (backfillStatus === "none") text = "Nothing to backfill for this event.";
else if (backfillStatus === "done" || backfillStatus === "invalid") {
text = `Backfilled ${backfill.size} of ${state.backfillAttempted} missing entries.`;
if (backfillStatus === "invalid") text += " Token invalid or expired — stopped early.";
else if (backfill.size < state.backfillAttempted) text += " Some lists had no extractable faction/disposition — see browser console.";
}
tokenStatusEl.textContent = text;
}
$("ev-name").textContent = event.name || "Untitled Event";
const parts = [fmtDateRange(event), [event.locationName, event.city].filter(Boolean).join(", ")]
.filter(Boolean);
if (event.numberOfRounds) parts.push(`${event.numberOfRounds} rounds`);
$("ev-sub").textContent = parts.join(" · ") || "—";
const statusEl = $("ev-status");
if (event.ended) { statusEl.textContent = "Ended"; statusEl.className = "badge badge-lg badge-neutral"; }
else if (event.active) { statusEl.textContent = "In Progress"; statusEl.className = "badge badge-lg badge-success"; }
else { statusEl.textContent = "Upcoming"; statusEl.className = "badge badge-lg badge-warning"; }
$("s-players").textContent = roster.length;
$("s-teams-wrap").classList.toggle("hidden", !isTeamEvent);
$("s-teams").textContent = teams.length;
$("s-checkin").textContent = pct(roster.filter(r => r.checkedIn).length, roster.length);
$("s-lists").textContent = pct(roster.filter(r => r.listUrl).length, roster.length);
$("insights").innerHTML = computeInsights(roster, teams, isTeamEvent)
.map(html => `<li class="flex items-start gap-2 text-sm"><span class="text-primary mt-0.5">▸</span><span>${html}</span></li>`)
.join("") || `<li class="text-sm opacity-50">Not enough data yet.</li>`;
renderFactionBreakdown($("faction-bars"), computeFactionCounts(roster), computeDispositionsByFaction(roster));
const dispositions = computeDispositionCounts(roster);
renderBars($("disposition-bars"), dispositions, dispositionExpanded ? null : 10);
$("disposition-toggle").classList.toggle("hidden", dispositions.length <= 10);
$("disposition-toggle").textContent = dispositionExpanded ? "Show top 10" : "Show all";
$("team-list").classList.toggle("hidden", !isTeamEvent);
$("player-table-wrap").classList.toggle("hidden", isTeamEvent);
const filterText = $("player-filter").value;
if (isTeamEvent) {
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, careerRecords, careerLoading, filterText);
}
$("loading").classList.add("hidden");
$("empty").classList.add("hidden");
$("results").classList.remove("hidden");
}
function startBackfill(capturedEvent) {
const token = getToken();
if (!token || !state) return;
state.backfillStatus = "loading";
render();
backfillFromLists(state.roster, token).then(({ backfill, tokenInvalid, attempted }) => {
if (!state || state.event !== capturedEvent) return; // a newer event loaded meanwhile
state.backfill = backfill;
state.backfillAttempted = attempted;
state.backfillStatus = tokenInvalid ? "invalid" : (attempted === 0 ? "none" : "done");
render();
});
}
async function loadEvent(rawInput) {
const id = parseEventId(rawInput);
if (!id) {
showError("That doesn't look like a valid BCP event link or ID. Paste something like https://www.bestcoastpairings.com/event/oLpMmIbQMOF1");
return;
}
clearBanner();
$("results").classList.add("hidden");
$("empty").classList.add("hidden");
$("loading").classList.remove("hidden");
const url = new URL(location.href);
url.searchParams.set("event", id);
history.replaceState(null, "", url);
try {
const [event, roster, teamplayers] = await Promise.all([
fetchEvent(id),
fetchRoster(id),
fetchTeamplayers(id).catch(() => []), // individual events have no teamplayers
]);
const isTeamEvent = teamplayers.length > 0;
state = {
event, roster, teamplayers, isTeamEvent,
itcRanks: new Map(), itcLoading: true,
careerRecords: new Map(), careerLoading: true,
backfill: new Map(), backfillStatus: "idle", backfillAttempted: 0,
};
render();
const userIds = new Set(roster.map(r => r.userId || r.user?.id).filter(Boolean));
loadItcRanks(userIds).then(ranks => {
if (state && state.event === event) {
state.itcRanks = ranks;
state.itcLoading = false;
render();
}
});
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);
if (msg.includes("404")) showError("Couldn't find that event. Double-check the link or ID.");
else if (msg.includes("401") || msg.includes("403")) showError("This event's roster isn't publicly visible.");
else showError(`Something went wrong reaching BCP: ${msg}`);
}
}
/* ---- Wire up ---- */
$("scout-form").addEventListener("submit", e => {
e.preventDefault();
loadEvent($("event-input").value);
});
$("player-filter").addEventListener("input", () => {
if (state) render();
});
$("disposition-toggle").addEventListener("click", () => {
dispositionExpanded = !dispositionExpanded;
if (state) render();
});
$("bcp-token-input").value = getToken();
$("bcp-token-save").addEventListener("click", () => {
setToken($("bcp-token-input").value.trim());
if (state) startBackfill(state.event);
});
$("bcp-token-clear").addEventListener("click", () => {
setToken("");
$("bcp-token-input").value = "";
$("bcp-token-status").textContent = "Cleared.";
});
/* ---- Boot: seed from ?event= if present ---- */
const seeded = new URLSearchParams(location.search).get("event");
if (seeded) {
$("event-input").value = seeded;
loadEvent(seeded);
}
+12
View File
@@ -0,0 +1,12 @@
@import "tailwindcss";
@plugin "daisyui";
/* Data-viz accent (sequential blue, single hue) — dataviz skill palette, blue slot */
:root[data-theme="dark"] { --viz-accent: #3987e5; }
:root[data-theme="light"] { --viz-accent: #2a78d6; }
html:not([data-theme]) { --viz-accent: #3987e5; }
.viz-bar {
background: var(--viz-accent);
border-radius: 0 4px 4px 0;
}
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from 'vite';
import { resolve } from 'path';
import { fileURLToPath } from 'url';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
export default defineConfig({
build: {
rollupOptions: {
input: {
index: resolve(__dirname, 'index.html'),
}
}
}
});