Add RTT/GT scope tag and a format/scope filter bar

BCP has no structured scope field (eventType/eventSubType are always
empty), and name-matching against RTT/GT/Major only covered ~55% of
events while systematically missing well-known majors that don't
literally say "GT" (ATC, 8TC, the Gateway Open...). Instead: RTT vs GT
is inferred from event duration (single day vs multi-day), Singles
events only, no Major tag — per team captain's call.

Duration has to be computed in each event's own timezone, not raw UTC
or the viewer's browser timezone: BCP end-times often land on the next
UTC calendar date purely from offset (a Chicago RTT running 9am-9pm
local reports as spanning two UTC dates). Verified against real RTT-
and GT-named events before shipping. The existing date-range display
had the same latent bug — fixed alongside this using the same
timezone-aware day comparison, so the badge and the displayed date
range can never visually contradict each other.

Also adds a filter bar (Format: Singles/Team Event, Scope: RTT/GT) —
toggling narrows the list per circuit without refetching, since the
already-fetched events are now kept around and just re-filtered.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SBzcNkW7JcYipnAX6HfgmK
This commit is contained in:
2026-09-04 16:26:51 -05:00
co-authored by Claude Sonnet 5
parent 17093e3d25
commit 83b6a2069d
2 changed files with 104 additions and 29 deletions
+10
View File
@@ -18,6 +18,16 @@
<p class="text-sm opacity-50 uppercase tracking-widest mt-1">Across the circuits Gateway Gamers follows</p>
</div>
<!-- Filters: toggle any combination off to narrow the list below -->
<div class="flex flex-wrap items-center gap-2 mb-8" id="filters">
<span class="text-xs uppercase tracking-widest opacity-50 mr-1">Format</span>
<button type="button" class="btn btn-sm btn-outline btn-active" data-filter-group="format" data-filter-value="Singles">Singles</button>
<button type="button" class="btn btn-sm btn-outline btn-active" data-filter-group="format" data-filter-value="Team Event">Team Event</button>
<span class="text-xs uppercase tracking-widest opacity-50 ml-3 mr-1">Scope</span>
<button type="button" class="btn btn-sm btn-outline btn-active" data-filter-group="scope" data-filter-value="RTT">RTT</button>
<button type="button" class="btn btn-sm btn-outline btn-active" data-filter-group="scope" data-filter-value="GT">GT</button>
</div>
<!-- Loading skeleton -->
<div class="space-y-8" id="loading">
<div class="space-y-3">
+94 -29
View File
@@ -112,28 +112,46 @@ 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;
// 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 1011, 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
? `<span class="badge badge-ghost badge-sm">${ev.checkedInPlayers ?? 0}/${ev.numTickets} checked in</span>`
@@ -159,6 +211,8 @@ function eventCard(ev) {
? `<span class="badge badge-outline badge-sm">${esc(ev.gameSystemName)}</span>`
: "";
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">
@@ -166,19 +220,24 @@ function eventCard(ev) {
<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 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.eventDate, ev.eventEndDate)}</div>
<div class="font-mono text-sm text-warning">${fmtDateRange(ev)}</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>`;
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">
@@ -191,8 +250,19 @@ function circuitSection(circuit, events) {
}
/* ---- 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() {
const results = await Promise.all(
allResults = await Promise.all(
CONFIG.circuits.map(async circuit => {
try {
return { circuit, events: await fetchUpcomingEvents(circuit.id) };
@@ -204,14 +274,9 @@ async function load() {
);
$("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("");
$("circuits").classList.remove("hidden");
renderCircuits();
}
setupFilters();
load();