Files
gateway-gamers.net/sites/events/src/main.js
T
mandaloreandClaude Sonnet 5 17093e3d25 Fix awkward multi-day event date formatting
Same-month ranges were rendering as e.g. "Sat, Oct 10, 2026 – 11" (full
start date, then a bare trailing day number for the end). Now:
single day "Sat, Oct 17, 2026", same-month range "Oct 10–11, 2026",
cross-month "Oct 30 – Nov 1, 2026", cross-year "Dec 30, 2026 – Jan 2, 2027".

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

218 lines
8.1 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
============================================================ */
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" },
],
};
/* ---- 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]));
}
function fmtDateRange(startIso, endIso) {
const start = new Date(startIso);
const end = endIso ? new Date(endIso) : null;
// Single day: "Sat, Oct 17, 2026"
if (!end || start.toDateString() === end.toDateString()) {
return start.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric", year: "numeric" });
}
const sameYear = start.getFullYear() === end.getFullYear();
const sameMonth = sameYear && start.getMonth() === end.getMonth();
// Same month: "Oct 1011, 2026"
if (sameMonth) {
const month = start.toLocaleDateString(undefined, { month: "short" });
return `${month} ${start.getDate()}${end.getDate()}, ${end.getFullYear()}`;
}
// Different month (or year): "Oct 30 Nov 1, 2026" / "Dec 30, 2026 Jan 2, 2027"
const startStr = start.toLocaleDateString(undefined, { month: "short", day: "numeric", year: sameYear ? undefined : "numeric" });
const endStr = end.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
return `${startStr} ${endStr}`;
}
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";
}
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>`
: "";
const format = `<span class="badge badge-warning badge-sm">${formatOf(ev)}</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}${system}${capacity}</div>
</div>
<div class="text-right shrink-0">
<div class="font-mono text-sm text-warning">${fmtDateRange(ev.eventDate, ev.eventEndDate)}</div>
</div>
</div>
</a>`;
}
function circuitSection(circuit, events) {
const body = events.length
? events.map(eventCard).join("")
: `<p class="text-sm text-base-content/50 py-4">No upcoming events for this circuit.</p>`;
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 ---- */
async function load() {
const results = await Promise.all(
CONFIG.circuits.map(async circuit => {
try {
return { circuit, events: await fetchUpcomingEvents(circuit.id) };
} catch (err) {
console.error("Failed to load circuit", circuit.id, err);
return { circuit, events: [], error: true };
}
})
);
$("loading").classList.add("hidden");
const container = $("circuits");
container.classList.remove("hidden");
container.innerHTML = results.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)
).join("");
}
load();