/* ---- 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'); /* ============================================================ CONFIG — the circuits shown on the page. Add/remove/edit entries here; the page builds one section per circuit. `id` is the leagueId from a circuit's BCP calendar URL, e.g. bestcoastpairings.com/circuit//calendar Optional `near: { lat, lon, miles }` restricts a circuit to only events within that radius (great-circle distance) — a fixed property of the circuit itself, unlike the Format/Scope toggles which the viewer controls. Events with no coordinate data are excluded rather than assumed to be in range. ============================================================ */ // St. Louis, MO — reference point for circuits restricted by distance. const ST_LOUIS = { lat: 38.6270, lon: -90.1994 }; const CONFIG = { circuits: [ { id: "Teqdr6p0aGvO", name: "Away Games 2025/26 ITC Season" }, { id: "t5kk3NvGDMXc", name: "National Tabletop League (NTL) 8-Player Circuit" }, { id: "m4OIU24N0IZO", name: "TEO 2026 Circuit" }, { id: "cY0LeCAPfyiG", name: "2026 Lord Marshal Conference" }, // The broader national league, not a "circuit" in BCP's own sense — // restricted to 250mi of St. Louis or this would show ~500 events // nationwide instead of the ~20 actually worth seeing locally. { id: "BYaaUfKum7z0", name: "Warhammer Global Rankings 2026", near: { ...ST_LOUIS, miles: 250 } }, { id: "LtAtngHNe3", name: "Old Circuit Token" } ], }; // Great-circle distance in miles between two lat/lon points (haversine). function milesBetween(lat1, lon1, lat2, lon2) { const R = 3958.8; const toRad = d => (d * Math.PI) / 180; const dLat = toRad(lat2 - lat1); const dLon = toRad(lon2 - lon1); const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2; return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); } function withinRadius(ev, near) { if (!near) return true; const coord = ev.coordinate; // BCP gives [lon, lat] if (!Array.isArray(coord) || coord.length !== 2) return false; const [lon, lat] = coord; return milesBetween(lat, lon, near.lat, near.lon) <= near.miles; } /* ---- 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"; // Cursor-paginated events for a league/circuit, newest-date-first (BCP // returns the same cursor when there are no more results — stop when it // loops, same guard as the other sites' streamPlacings). async function* streamEvents(leagueId) { const base = `${API}/events?leagueId=${leagueId}&sortAscending=false&limit=50`; 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("events/" + r.status); const p = await r.json(); const page = Array.isArray(p) ? p : (p.data || []); 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); } // Sorted newest-first, so we can stop as soon as we cross into the past // instead of paging through a circuit's entire history every time. async function fetchUpcomingEvents(leagueId) { const now = Date.now(); const upcoming = []; for await (const page of streamEvents(leagueId)) { if (!page.length) break; for (const ev of page) { const end = Date.parse(ev.eventEndDate || ev.eventDate); if (Number.isFinite(end) && end >= now) upcoming.push(ev); } // Page is sorted by start date descending, so once the oldest (last) // event here has already ended, every event on later pages — all with // even older start dates — will have ended too. Stop paging. const last = page[page.length - 1]; const lastEnd = Date.parse(last.eventEndDate || last.eventDate); if (!Number.isFinite(lastEnd) || lastEnd < now) break; } return upcoming.reverse(); // soonest first } /* ---- Render ---- */ const $ = id => document.getElementById(id); function esc(s) { return String(s).replace(/[&<>"]/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); } // BCP end-times often land on the next UTC calendar date purely from // timezone offset — a Chicago RTT running 9am-9pm local reports as // spanning two UTC dates. Comparing calendar days in the *event's own* // timezone (not the viewer's browser timezone, not raw UTC) is the only // way to get a stable answer regardless of who's looking at the page. function localDateKey(date, timeZone) { return new Intl.DateTimeFormat("en-CA", { timeZone, year: "numeric", month: "2-digit", day: "2-digit" }).format(date); } function dateParts(date, timeZone) { const parts = new Intl.DateTimeFormat("en-US", { timeZone, year: "numeric", month: "short", day: "numeric", weekday: "short" }).formatToParts(date); return Object.fromEntries(parts.map(p => [p.type, p.value])); } function sameCalendarDay(ev) { if (!ev.eventEndDate) return true; const tz = ev.timeZone || undefined; return localDateKey(new Date(ev.eventDate), tz) === localDateKey(new Date(ev.eventEndDate), tz); } function fmtDateRange(ev) { const tz = ev.timeZone || undefined; const start = new Date(ev.eventDate); const sp = dateParts(start, tz); // Single day: "Sat, Oct 17, 2026" if (!ev.eventEndDate || sameCalendarDay(ev)) { return `${sp.weekday}, ${sp.month} ${sp.day}, ${sp.year}`; } const ep = dateParts(new Date(ev.eventEndDate), tz); const sameMonth = sp.month === ep.month && sp.year === ep.year; // Same month: "Oct 10–11, 2026" if (sameMonth) return `${sp.month} ${sp.day}–${ep.day}, ${ep.year}`; // Different month (or year): "Oct 30 – Nov 1, 2026" / "Dec 30, 2026 – Jan 2, 2027" const sameYear = sp.year === ep.year; const startStr = sameYear ? `${sp.month} ${sp.day}` : `${sp.month} ${sp.day}, ${sp.year}`; return `${startStr} – ${ep.month} ${ep.day}, ${ep.year}`; } function locationOf(ev) { const parts = [ev.locationName, ev.city, ev.country].filter(Boolean); return parts.length ? parts.join(", ") : (ev.formatted_address || "Location TBD"); } // BCP's only reliable format signal is the teamEvent flag. There's no // dedicated "team size" field — the closest candidate (totalPlayers / // totalTeamPlayers) reflects live registration progress, not the event's // configured format, so it's noisy/wrong for events with thin early // signups (which is most upcoming events). Deliberately not guessing a // specific team size here. function formatOf(ev) { return ev.teamEvent ? "Team Event" : "Singles"; } // BCP has no structured field for event scope (RTT/GT/Major) — name- // matching only covers ~55% of events and systematically misses // well-known majors whose names don't say "GT" (ATC, 8TC, the Gateway // Open...). Instead: RTT vs GT is inferred from duration (single day vs // multi-day, in the event's own timezone — see sameCalendarDay), and // only applies to Singles events. Team events don't get a scope tag, // and there's no "Major" tag — both per team captain's call. function scopeOf(ev) { if (ev.teamEvent) return null; return sameCalendarDay(ev) ? "RTT" : "GT"; } /* ---- Filters ---- */ const activeFormats = new Set(["Singles", "Team Event"]); const activeScopes = new Set(["RTT", "GT"]); const activeCircuits = new Set(CONFIG.circuits.map(c => c.id)); // An event passes the circuit filter if it belongs to at least one // active circuit — it's an OR within the group, same as Format/Scope, // not "must match every circuit it happens to also belong to". function passesFilter(ev, circuits) { const scope = scopeOf(ev); return activeFormats.has(formatOf(ev)) && (scope === null || activeScopes.has(scope)) && circuits.some(c => activeCircuits.has(c.id)); } // Filter buttons use the same color as the badge they control (warning // for Format, info for Scope, accent for Circuit — matching the badge // colors on the cards) when active, so it's obvious at a glance which is // which — and switch to a dimmed outline when off, since btn-outline // alone reads too similarly to its own active state in dark mode to // tell the two apart at a glance. const FILTER_GROUP_COLOR = { format: "btn-warning", scope: "btn-info", circuit: "btn-accent" }; const FILTER_SET = { format: activeFormats, scope: activeScopes, circuit: activeCircuits }; // Circuit buttons are generated from CONFIG rather than hand-written in // index.html, so adding a circuit there is still a one-line change. function renderCircuitFilterButtons() { $("circuit-filters").innerHTML = CONFIG.circuits.map(c => `` ).join(""); } function setupFilters() { renderCircuitFilterButtons(); document.querySelectorAll("#filters button[data-filter-group]").forEach(btn => { const group = btn.dataset.filterGroup; const onClass = FILTER_GROUP_COLOR[group]; btn.addEventListener("click", () => { const set = FILTER_SET[group]; const nowActive = !btn.classList.contains(onClass); btn.classList.toggle(onClass, nowActive); btn.classList.toggle("btn-outline", !nowActive); btn.classList.toggle("opacity-40", !nowActive); if (nowActive) set.add(btn.dataset.filterValue); else set.delete(btn.dataset.filterValue); renderEvents(); }); }); } // A single event can belong to more than one watched circuit (BCP events // carry their own list of leagues, and it's common for e.g. a national // league and a regional circuit to both include the same event) — badge // it with every circuit it matched under rather than showing it once // per circuit. function eventCard(ev, circuits) { const capacity = ev.numTickets ? `${ev.checkedInPlayers ?? 0}/${ev.numTickets} checked in` : ""; const system = ev.gameSystemName ? `${esc(ev.gameSystemName)}` : ""; const format = `${formatOf(ev)}`; const scope = scopeOf(ev); const scopeBadge = scope ? `${scope}` : ""; const circuitBadges = circuits.map(c => `${esc(c.name)}`).join(""); return `
${esc(ev.name)}
${esc(locationOf(ev))}
${format}${scopeBadge}${circuitBadges}${system}${capacity}
${fmtDateRange(ev)}
`; } // Dedupe by event id across all circuits, collecting every circuit an // event matched under, then sort the merged list chronologically — // replaces the old one-section-per-circuit layout with a single feed. function mergeEvents(results) { const byId = new Map(); for (const { circuit, events, error } of results) { if (error) continue; for (const ev of events) { if (!byId.has(ev.id)) byId.set(ev.id, { event: ev, circuits: [] }); byId.get(ev.id).circuits.push(circuit); } } return [...byId.values()].sort((a, b) => Date.parse(a.event.eventDate) - Date.parse(b.event.eventDate)); } /* ---- Orchestration ---- */ let allResults = []; function renderEvents() { const merged = mergeEvents(allResults); const filtered = merged.filter(({ event, circuits }) => passesFilter(event, circuits)); const failed = allResults.filter(r => r.error).map(r => r.circuit.name); const errorBanner = failed.length ? `

Couldn't load: ${failed.map(esc).join(", ")} — try refreshing.

` : ""; let body; if (!merged.length) { body = `

No upcoming events found.

`; } else if (!filtered.length) { body = `

No events match the current filter.

`; } else { body = `
${filtered.map(({ event, circuits }) => eventCard(event, circuits)).join("")}
`; } $("events").innerHTML = errorBanner + body; } async function load() { allResults = await Promise.all( CONFIG.circuits.map(async circuit => { try { const events = await fetchUpcomingEvents(circuit.id); return { circuit, events: events.filter(ev => withinRadius(ev, circuit.near)) }; } catch (err) { console.error("Failed to load circuit", circuit.id, err); return { circuit, events: [], error: true }; } }) ); $("loading").classList.add("hidden"); $("events").classList.remove("hidden"); renderEvents(); } setupFilters(); load();