Files
gateway-gamers.net/sites/events/src/main.js
T
mandaloreandClaude Sonnet 5 4bb5dc8ed8 Consolidate into one chronological list, tag each event by circuit
Replaces the one-section-per-circuit layout with a single flat,
deduped, date-sorted feed. Events belonging to more than one watched
circuit (real and not rare — verified 2 current events span both a
regional circuit and the national league) get one card carrying every
circuit badge that matched, rather than appearing once per section.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SBzcNkW7JcYipnAX6HfgmK
2026-09-04 17:08:11 -05:00

339 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ---- 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/<id>/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 } },
],
};
// 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 => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[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 1011, 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"]);
function passesFilter(ev) {
const scope = scopeOf(ev);
return activeFormats.has(formatOf(ev)) && (scope === null || activeScopes.has(scope));
}
// Filter buttons use the same color as the badge they control (warning
// for Format, info for Scope) 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" };
function setupFilters() {
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 = group === "format" ? activeFormats : activeScopes;
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
? `<span class="badge badge-ghost badge-sm">${ev.checkedInPlayers ?? 0}/${ev.numTickets} checked in</span>`
: "";
const system = ev.gameSystemName
? `<span class="badge badge-outline badge-sm">${esc(ev.gameSystemName)}</span>`
: "";
const format = `<span class="badge badge-warning badge-sm">${formatOf(ev)}</span>`;
const scope = scopeOf(ev);
const scopeBadge = scope ? `<span class="badge badge-info badge-sm">${scope}</span>` : "";
const circuitBadges = circuits.map(c => `<span class="badge badge-accent badge-sm">${esc(c.name)}</span>`).join("");
return `
<a href="${BCP_ORIGIN}/event/${esc(ev.id)}" target="_blank" rel="noopener"
class="card card-border bg-base-200 hover:border-warning transition-colors">
<div class="card-body py-4 flex-row items-center justify-between gap-4 flex-wrap">
<div class="min-w-0">
<div class="font-semibold truncate">${esc(ev.name)}</div>
<div class="text-sm opacity-60 mt-1">${esc(locationOf(ev))}</div>
<div class="flex gap-2 mt-2 flex-wrap">${format}${scopeBadge}${circuitBadges}${system}${capacity}</div>
</div>
<div class="text-right shrink-0">
<div class="font-mono text-sm text-warning">${fmtDateRange(ev)}</div>
</div>
</div>
</a>`;
}
// 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 }) => passesFilter(event));
const failed = allResults.filter(r => r.error).map(r => r.circuit.name);
const errorBanner = failed.length
? `<p class="text-sm text-error mb-4">Couldn't load: ${failed.map(esc).join(", ")} — try refreshing.</p>`
: "";
let body;
if (!merged.length) {
body = `<p class="text-sm text-base-content/50 py-4">No upcoming events found.</p>`;
} else if (!filtered.length) {
body = `<p class="text-sm text-base-content/50 py-4">No events match the current filter.</p>`;
} else {
body = `<div class="space-y-3">${filtered.map(({ event, circuits }) => eventCard(event, circuits)).join("")}</div>`;
}
$("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();