2026-09-04 15:55:25 -05:00
|
|
|
|
/* ---- 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
|
2026-09-04 16:30:58 -05:00
|
|
|
|
|
|
|
|
|
|
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.
|
2026-09-04 15:55:25 -05:00
|
|
|
|
============================================================ */
|
2026-09-04 16:32:15 -05:00
|
|
|
|
// St. Louis, MO — reference point for circuits restricted by distance.
|
|
|
|
|
|
const ST_LOUIS = { lat: 38.6270, lon: -90.1994 };
|
|
|
|
|
|
|
2026-09-04 15:55:25 -05:00
|
|
|
|
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" },
|
2026-09-04 16:32:15 -05:00
|
|
|
|
// The broader national league, not a "circuit" in BCP's own sense —
|
2026-09-04 16:34:06 -05:00
|
|
|
|
// 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-04 15:55:25 -05:00
|
|
|
|
],
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-09-04 16:30:58 -05:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-04 15:55:25 -05:00
|
|
|
|
/* ---- 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]));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-04 16:26:51 -05:00
|
|
|
|
// 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"
|
2026-09-04 16:26:51 -05:00
|
|
|
|
if (!ev.eventEndDate || sameCalendarDay(ev)) {
|
|
|
|
|
|
return `${sp.weekday}, ${sp.month} ${sp.day}, ${sp.year}`;
|
2026-09-04 16:19:00 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-04 16:26:51 -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 10–11, 2026"
|
2026-09-04 16:26:51 -05:00
|
|
|
|
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"
|
2026-09-04 16:26:51 -05:00
|
|
|
|
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}`;
|
2026-09-04 15:55:25 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-04 16:26:51 -05:00
|
|
|
|
// 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 16:26:51 -05:00
|
|
|
|
|
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) {
|
2026-09-04 16:26:51 -05:00
|
|
|
|
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));
|
2026-09-04 16:26:51 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-04 16:38:21 -05:00
|
|
|
|
// 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("");
|
|
|
|
|
|
}
|
2026-09-04 16:38:21 -05:00
|
|
|
|
|
2026-09-04 16:26:51 -05:00
|
|
|
|
function setupFilters() {
|
2026-09-04 17:10:20 -05:00
|
|
|
|
renderCircuitFilterButtons();
|
2026-09-04 16:26:51 -05:00
|
|
|
|
document.querySelectorAll("#filters button[data-filter-group]").forEach(btn => {
|
2026-09-04 16:38:21 -05:00
|
|
|
|
const group = btn.dataset.filterGroup;
|
|
|
|
|
|
const onClass = FILTER_GROUP_COLOR[group];
|
2026-09-04 16:26:51 -05:00
|
|
|
|
btn.addEventListener("click", () => {
|
2026-09-04 17:10:20 -05:00
|
|
|
|
const set = FILTER_SET[group];
|
2026-09-04 16:38:21 -05:00
|
|
|
|
const nowActive = !btn.classList.contains(onClass);
|
|
|
|
|
|
btn.classList.toggle(onClass, nowActive);
|
|
|
|
|
|
btn.classList.toggle("btn-outline", !nowActive);
|
|
|
|
|
|
btn.classList.toggle("opacity-40", !nowActive);
|
2026-09-04 16:26:51 -05:00
|
|
|
|
if (nowActive) set.add(btn.dataset.filterValue);
|
|
|
|
|
|
else set.delete(btn.dataset.filterValue);
|
2026-09-04 17:08:11 -05:00
|
|
|
|
renderEvents();
|
2026-09-04 16:26:51 -05:00
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-04 17:08:11 -05:00
|
|
|
|
// 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) {
|
2026-09-04 15:55:25 -05:00
|
|
|
|
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>`;
|
2026-09-04 16:26:51 -05:00
|
|
|
|
const scope = scopeOf(ev);
|
|
|
|
|
|
const scopeBadge = scope ? `<span class="badge badge-info badge-sm">${scope}</span>` : "";
|
2026-09-04 17:08:11 -05:00
|
|
|
|
const circuitBadges = circuits.map(c => `<span class="badge badge-accent badge-sm">${esc(c.name)}</span>`).join("");
|
2026-09-04 15:55:25 -05:00
|
|
|
|
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>
|
2026-09-04 17:08:11 -05:00
|
|
|
|
<div class="flex gap-2 mt-2 flex-wrap">${format}${scopeBadge}${circuitBadges}${system}${capacity}</div>
|
2026-09-04 15:55:25 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="text-right shrink-0">
|
2026-09-04 16:26:51 -05:00
|
|
|
|
<div class="font-mono text-sm text-warning">${fmtDateRange(ev)}</div>
|
2026-09-04 15:55:25 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</a>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-04 17:08:11 -05:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
2026-09-04 16:26:51 -05:00
|
|
|
|
}
|
2026-09-04 17:08:11 -05:00
|
|
|
|
return [...byId.values()].sort((a, b) => Date.parse(a.event.eventDate) - Date.parse(b.event.eventDate));
|
2026-09-04 15:55:25 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ---- Orchestration ---- */
|
2026-09-04 16:26:51 -05:00
|
|
|
|
let allResults = [];
|
|
|
|
|
|
|
2026-09-04 17:08:11 -05:00
|
|
|
|
function renderEvents() {
|
|
|
|
|
|
const merged = mergeEvents(allResults);
|
2026-09-04 17:10:20 -05:00
|
|
|
|
const filtered = merged.filter(({ event, circuits }) => passesFilter(event, circuits));
|
2026-09-04 17:08:11 -05:00
|
|
|
|
|
|
|
|
|
|
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;
|
2026-09-04 16:26:51 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-04 15:55:25 -05:00
|
|
|
|
async function load() {
|
2026-09-04 16:26:51 -05:00
|
|
|
|
allResults = await Promise.all(
|
2026-09-04 15:55:25 -05:00
|
|
|
|
CONFIG.circuits.map(async circuit => {
|
|
|
|
|
|
try {
|
2026-09-04 16:30:58 -05:00
|
|
|
|
const events = await fetchUpcomingEvents(circuit.id);
|
|
|
|
|
|
return { circuit, events: events.filter(ev => withinRadius(ev, circuit.near)) };
|
2026-09-04 15:55:25 -05:00
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error("Failed to load circuit", circuit.id, err);
|
|
|
|
|
|
return { circuit, events: [], error: true };
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
$("loading").classList.add("hidden");
|
2026-09-04 17:08:11 -05:00
|
|
|
|
$("events").classList.remove("hidden");
|
|
|
|
|
|
renderEvents();
|
2026-09-04 15:55:25 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-04 16:26:51 -05:00
|
|
|
|
setupFilters();
|
2026-09-04 15:55:25 -05:00
|
|
|
|
load();
|