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:
@@ -18,6 +18,16 @@
|
|||||||
<p class="text-sm opacity-50 uppercase tracking-widest mt-1">Across the circuits Gateway Gamers follows</p>
|
<p class="text-sm opacity-50 uppercase tracking-widest mt-1">Across the circuits Gateway Gamers follows</p>
|
||||||
</div>
|
</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 -->
|
<!-- Loading skeleton -->
|
||||||
<div class="space-y-8" id="loading">
|
<div class="space-y-8" id="loading">
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
|
|||||||
+94
-29
@@ -112,28 +112,46 @@ function esc(s) {
|
|||||||
return String(s).replace(/[&<>"]/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
return String(s).replace(/[&<>"]/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtDateRange(startIso, endIso) {
|
// BCP end-times often land on the next UTC calendar date purely from
|
||||||
const start = new Date(startIso);
|
// timezone offset — a Chicago RTT running 9am-9pm local reports as
|
||||||
const end = endIso ? new Date(endIso) : null;
|
// 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"
|
// Single day: "Sat, Oct 17, 2026"
|
||||||
if (!end || start.toDateString() === end.toDateString()) {
|
if (!ev.eventEndDate || sameCalendarDay(ev)) {
|
||||||
return start.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric", year: "numeric" });
|
return `${sp.weekday}, ${sp.month} ${sp.day}, ${sp.year}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sameYear = start.getFullYear() === end.getFullYear();
|
const ep = dateParts(new Date(ev.eventEndDate), tz);
|
||||||
const sameMonth = sameYear && start.getMonth() === end.getMonth();
|
const sameMonth = sp.month === ep.month && sp.year === ep.year;
|
||||||
|
|
||||||
// Same month: "Oct 10–11, 2026"
|
// Same month: "Oct 10–11, 2026"
|
||||||
if (sameMonth) {
|
if (sameMonth) return `${sp.month} ${sp.day}–${ep.day}, ${ep.year}`;
|
||||||
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"
|
// 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 sameYear = sp.year === ep.year;
|
||||||
const endStr = end.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
|
const startStr = sameYear ? `${sp.month} ${sp.day}` : `${sp.month} ${sp.day}, ${sp.year}`;
|
||||||
return `${startStr} – ${endStr}`;
|
return `${startStr} – ${ep.month} ${ep.day}, ${ep.year}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function locationOf(ev) {
|
function locationOf(ev) {
|
||||||
@@ -151,6 +169,40 @@ function formatOf(ev) {
|
|||||||
return ev.teamEvent ? "Team Event" : "Singles";
|
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) {
|
function eventCard(ev) {
|
||||||
const capacity = ev.numTickets
|
const capacity = ev.numTickets
|
||||||
? `<span class="badge badge-ghost badge-sm">${ev.checkedInPlayers ?? 0}/${ev.numTickets} checked in</span>`
|
? `<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>`
|
? `<span class="badge badge-outline badge-sm">${esc(ev.gameSystemName)}</span>`
|
||||||
: "";
|
: "";
|
||||||
const format = `<span class="badge badge-warning badge-sm">${formatOf(ev)}</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 `
|
return `
|
||||||
<a href="${BCP_ORIGIN}/event/${esc(ev.id)}" target="_blank" rel="noopener"
|
<a href="${BCP_ORIGIN}/event/${esc(ev.id)}" target="_blank" rel="noopener"
|
||||||
class="card card-border bg-base-200 hover:border-warning transition-colors">
|
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="min-w-0">
|
||||||
<div class="font-semibold truncate">${esc(ev.name)}</div>
|
<div class="font-semibold truncate">${esc(ev.name)}</div>
|
||||||
<div class="text-sm opacity-60 mt-1">${esc(locationOf(ev))}</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>
|
||||||
<div class="text-right shrink-0">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</a>`;
|
</a>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function circuitSection(circuit, events) {
|
function circuitSection(circuit, events, filtered) {
|
||||||
const body = events.length
|
let body;
|
||||||
? events.map(eventCard).join("")
|
if (!events.length) {
|
||||||
: `<p class="text-sm text-base-content/50 py-4">No upcoming events for this circuit.</p>`;
|
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 `
|
return `
|
||||||
<section>
|
<section>
|
||||||
<h2 class="text-2xl font-bold uppercase tracking-wide mb-3">
|
<h2 class="text-2xl font-bold uppercase tracking-wide mb-3">
|
||||||
@@ -191,8 +250,19 @@ function circuitSection(circuit, events) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Orchestration ---- */
|
/* ---- 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() {
|
async function load() {
|
||||||
const results = await Promise.all(
|
allResults = await Promise.all(
|
||||||
CONFIG.circuits.map(async circuit => {
|
CONFIG.circuits.map(async circuit => {
|
||||||
try {
|
try {
|
||||||
return { circuit, events: await fetchUpcomingEvents(circuit.id) };
|
return { circuit, events: await fetchUpcomingEvents(circuit.id) };
|
||||||
@@ -204,14 +274,9 @@ async function load() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
$("loading").classList.add("hidden");
|
$("loading").classList.add("hidden");
|
||||||
const container = $("circuits");
|
$("circuits").classList.remove("hidden");
|
||||||
container.classList.remove("hidden");
|
renderCircuits();
|
||||||
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("");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setupFilters();
|
||||||
load();
|
load();
|
||||||
|
|||||||
Reference in New Issue
Block a user