Files
gateway-gamers.net/sites/events/src/main.js
T

357 lines
14 KiB
JavaScript
Raw Normal View History

/* ---- 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 } },
2026-09-05 12:06:48 -05:00
{ 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 => ({ "&": "&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);
2026-09-04 16:19:00 -05:00
// Single day: "Sat, Oct 17, 2026"
if (!ev.eventEndDate || sameCalendarDay(ev)) {
return `${sp.weekday}, ${sp.month} ${sp.day}, ${sp.year}`;
2026-09-04 16:19:00 -05:00
}
const ep = dateParts(new Date(ev.eventEndDate), tz);
const sameMonth = sp.month === ep.month && sp.year === ep.year;
2026-09-04 16:19:00 -05:00
// Same month: "Oct 1011, 2026"
if (sameMonth) return `${sp.month} ${sp.day}${ep.day}, ${ep.year}`;
2026-09-04 16:19:00 -05:00
// 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");
}
2026-09-04 16:04:42 -05:00
// 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"]);
2026-09-04 17:10:20 -05:00
const activeCircuits = new Set(CONFIG.circuits.map(c => c.id));
2026-09-04 17:10:20 -05:00
// 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);
2026-09-04 17:10:20 -05:00
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
2026-09-04 17:10:20 -05:00
// 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 =>
`<button type="button" class="btn btn-sm btn-accent" data-filter-group="circuit" data-filter-value="${esc(c.id)}">${esc(c.name)}</button>`
).join("");
}
function setupFilters() {
2026-09-04 17:10:20 -05:00
renderCircuitFilterButtons();
document.querySelectorAll("#filters button[data-filter-group]").forEach(btn => {
const group = btn.dataset.filterGroup;
const onClass = FILTER_GROUP_COLOR[group];
btn.addEventListener("click", () => {
2026-09-04 17:10:20 -05:00
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
? `<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>`
: "";
2026-09-04 16:04:42 -05:00
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);
2026-09-04 17:10:20 -05:00
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
? `<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();