feat: initial BCP roster scouting tool
Paste a Best Coast Pairings event link to get roster-only stats: faction/disposition distribution, per-team ITC rank rollups, and an optional list-backfill for players missing faction/disposition data (uses the viewer's own BCP auth token, stored client-side only). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.claude/settings.local.json
|
||||||
@@ -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
@@ -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-center">Checked In</th>
|
||||||
|
<th class="text-right">ITC Rank</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>
|
||||||
Generated
+1868
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
'@tailwindcss/postcss': {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<footer class="footer footer-center bg-base-300 text-base-content p-4">
|
||||||
|
<p>© 2026 Gateway Gamers. All rights reserved.</p>
|
||||||
|
</footer>
|
||||||
@@ -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,36 @@
|
|||||||
|
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;
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self' https://newprod-api.bestcoastpairings.com; img-src 'self' data:;" always;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ =404;
|
||||||
|
}
|
||||||
|
}
|
||||||
+706
@@ -0,0 +1,706 @@
|
|||||||
|
/* ---- 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 += `®ionId=${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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {} };
|
||||||
|
|
||||||
|
// 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()) || captured;
|
||||||
|
}
|
||||||
|
if (!disposition) {
|
||||||
|
for (const rawLine of text.split("\n")) {
|
||||||
|
const canonical = knownDisp.get(rawLine.trim().toLowerCase());
|
||||||
|
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 => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
||||||
|
const fullName = (u = {}) => [u.firstName, u.lastName].filter(Boolean).join(" ").trim();
|
||||||
|
const pct = (n, d) => d ? Math.round(n / d * 100) + "%" : "—";
|
||||||
|
|
||||||
|
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) {
|
||||||
|
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
|
||||||
|
? `<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 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-center">${checkedBadge}</td>
|
||||||
|
<td class="text-right font-mono text-sm">${rankLabel}</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, 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-center">Checked In</th>
|
||||||
|
<th class="text-right">ITC Rank</th>
|
||||||
|
<th class="text-right">List</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>${shown.map(m => playerRowHTML(m, itcRanks, itcLoading)).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, 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)).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, backfill, backfillStatus } = state;
|
||||||
|
const roster = applyBackfill(state.roster, backfill);
|
||||||
|
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, 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, 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,
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user