diff --git a/sites/events/src/main.js b/sites/events/src/main.js
index bbfa59f..46f5fb0 100644
--- a/sites/events/src/main.js
+++ b/sites/events/src/main.js
@@ -112,28 +112,46 @@ function esc(s) {
return String(s).replace(/[&<>"]/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
}
-function fmtDateRange(startIso, endIso) {
- const start = new Date(startIso);
- const end = endIso ? new Date(endIso) : null;
+// 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 (!end || start.toDateString() === end.toDateString()) {
- return start.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric", year: "numeric" });
+ if (!ev.eventEndDate || sameCalendarDay(ev)) {
+ return `${sp.weekday}, ${sp.month} ${sp.day}, ${sp.year}`;
}
- const sameYear = start.getFullYear() === end.getFullYear();
- const sameMonth = sameYear && start.getMonth() === end.getMonth();
+ const ep = dateParts(new Date(ev.eventEndDate), tz);
+ const sameMonth = sp.month === ep.month && sp.year === ep.year;
// Same month: "Oct 10–11, 2026"
- if (sameMonth) {
- const month = start.toLocaleDateString(undefined, { month: "short" });
- return `${month} ${start.getDate()}–${end.getDate()}, ${end.getFullYear()}`;
- }
+ 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 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}`;
+ 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) {
@@ -151,6 +169,40 @@ 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
? `
${ev.checkedInPlayers ?? 0}/${ev.numTickets} checked in`
@@ -159,6 +211,8 @@ function eventCard(ev) {
? `
${esc(ev.gameSystemName)}`
: "";
const format = `
${formatOf(ev)}`;
+ const scope = scopeOf(ev);
+ const scopeBadge = scope ? `
${scope}` : "";
return `
@@ -166,19 +220,24 @@ function eventCard(ev) {
${esc(ev.name)}
${esc(locationOf(ev))}
-
${format}${system}${capacity}
+
${format}${scopeBadge}${system}${capacity}
-
${fmtDateRange(ev.eventDate, ev.eventEndDate)}
+
${fmtDateRange(ev)}
`;
}
-function circuitSection(circuit, events) {
- const body = events.length
- ? events.map(eventCard).join("")
- : `
No upcoming events for this circuit.
`;
+function circuitSection(circuit, events, filtered) {
+ let body;
+ if (!events.length) {
+ body = `
No upcoming events for this circuit.
No events match the current filter.
`;
+ } else {
+ body = filtered.map(eventCard).join("");
+ }
return `