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

311 lines
12 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.
============================================================ */
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" },
],
};
// St. Louis, MO — reference point for circuits restricted by distance.
const ST_LOUIS = { lat: 38.6270, lon: -90.1994 };
// 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"]);
function passesFilter(ev) {
const scope = scopeOf(ev);
return activeFormats.has(formatOf(ev)) && (scope === null || activeScopes.has(scope));
}
function setupFilters() {
document.querySelectorAll("#filters button[data-filter-group]").forEach(btn => {
btn.addEventListener("click", () => {
const set = btn.dataset.filterGroup === "format" ? activeFormats : activeScopes;
const nowActive = !btn.classList.contains("btn-active");
btn.classList.toggle("btn-active", nowActive);
if (nowActive) set.add(btn.dataset.filterValue);
else set.delete(btn.dataset.filterValue);
renderCircuits();
});
});
}
function eventCard(ev) {
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>` : "";
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">${format}${scopeBadge}${system}${capacity}</div>
</div>
<div class="text-right shrink-0">
<div class="font-mono text-sm text-warning">${fmtDateRange(ev)}</div>
</div>
</div>
</a>`;
}
function circuitSection(circuit, events, filtered) {
let body;
if (!events.length) {
body = `<p class="text-sm text-base-content/50 py-4">No upcoming events for this circuit.</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 = filtered.map(eventCard).join("");
}
return `
<section>
<h2 class="text-2xl font-bold uppercase tracking-wide mb-3">
<a href="${BCP_ORIGIN}/circuit/${esc(circuit.id)}/calendar" target="_blank" rel="noopener" class="hover:text-warning">
${esc(circuit.name)}
</a>
</h2>
<div class="space-y-3">${body}</div>
</section>`;
}
/* ---- Orchestration ---- */
let allResults = [];
function renderCircuits() {
$("circuits").innerHTML = allResults.map(({ circuit, events, error }) =>
error
? `<section><h2 class="text-2xl font-bold uppercase tracking-wide mb-3">${esc(circuit.name)}</h2>
<p class="text-sm text-error py-4">Couldn't load events for this circuit — try refreshing.</p></section>`
: circuitSection(circuit, events, events.filter(passesFilter))
).join("");
}
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");
$("circuits").classList.remove("hidden");
renderCircuits();
}
setupFilters();
load();