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:
+94
-29
@@ -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
|
||||
? `<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();
|
||||
|
||||
Reference in New Issue
Block a user