From 91e09b05485406616306475463259e25c10b3c80 Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 4 Apr 2026 10:53:05 -0400 Subject: [PATCH] feat: detect passholder preview days and filter plain buyouts - Buyout days are now treated as closed unless they carry a Passholder Preview event, in which case they surface as a distinct purple cell in the UI showing "Passholder" + hours - DB gains a special_type column (auto-migrated on next startup) - scrape.ts threads specialType through to upsertDay - debug.ts now shows events, isBuyout, isPassholderPreview, and specialType in the parsed result section Co-Authored-By: Claude Sonnet 4.6 --- app/globals.css | 5 ++++ components/WeekCalendar.tsx | 46 +++++++++++++++++++++++++++++++++++++ lib/db.ts | 38 +++++++++++++++++++----------- lib/scrapers/sixflags.ts | 22 +++++++++++++++--- scripts/debug.ts | 27 ++++++++++++++-------- scripts/scrape.ts | 2 +- 6 files changed, 114 insertions(+), 26 deletions(-) diff --git a/app/globals.css b/app/globals.css index 5109702..55ac505 100644 --- a/app/globals.css +++ b/app/globals.css @@ -14,6 +14,11 @@ --color-open-text: #4ade80; --color-open-hours: #bbf7d0; + --color-ph-bg: #1e0f2e; + --color-ph-border: #7e22ce; + --color-ph-hours: #e9d5ff; + --color-ph-label: #c084fc; + --color-today-bg: #0c1a3d; --color-today-border: #2563eb; --color-today-text: #93c5fd; diff --git a/components/WeekCalendar.tsx b/components/WeekCalendar.tsx index 0c0598a..52e45b1 100644 --- a/components/WeekCalendar.tsx +++ b/components/WeekCalendar.tsx @@ -177,6 +177,52 @@ export function WeekCalendar({ parks, weekDates, data }: WeekCalendarProps) { ); } + // Passholder preview day + if (dayData.specialType === "passholder_preview") { + return ( + +
+ + Passholder + + + {dayData.hoursLabel} + +
+ + ); + } + // Open with confirmed hours return ( > { const rows = db .prepare( - `SELECT park_id, date, is_open, hours_label + `SELECT park_id, date, is_open, hours_label, special_type FROM park_days WHERE date >= ? AND date <= ?` ) @@ -74,6 +84,7 @@ export function getDateRange( date: string; is_open: number; hours_label: string | null; + special_type: string | null; }[]; const result: Record> = {}; @@ -82,6 +93,7 @@ export function getDateRange( result[row.park_id][row.date] = { isOpen: row.is_open === 1, hoursLabel: row.hours_label, + specialType: row.special_type, }; } return result; diff --git a/lib/scrapers/sixflags.ts b/lib/scrapers/sixflags.ts index 1922b02..a2bc1c4 100644 --- a/lib/scrapers/sixflags.ts +++ b/lib/scrapers/sixflags.ts @@ -34,6 +34,7 @@ export interface DayResult { date: string; // YYYY-MM-DD isOpen: boolean; hoursLabel?: string; + specialType?: "passholder_preview"; } function sleep(ms: number) { @@ -49,6 +50,7 @@ function parseApiDate(d: string): string { interface ApiOperatingItem { timeFrom: string; // "10:30" 24h timeTo: string; // "20:00" 24h + isBuyout?: boolean; } interface ApiOperating { @@ -56,9 +58,14 @@ interface ApiOperating { items: ApiOperatingItem[]; } +interface ApiEvent { + extEventName: string; +} + interface ApiDay { date: string; isParkClosed: boolean; + events?: ApiEvent[]; operatings?: ApiOperating[]; } @@ -135,9 +142,18 @@ export async function scrapeMonth( item?.timeFrom && item?.timeTo ? `${fmt24(item.timeFrom)} – ${fmt24(item.timeTo)}` : undefined; - // If the API says open but no hours are available, treat as closed - const isOpen = !d.isParkClosed && hoursLabel !== undefined; - return { date, isOpen, hoursLabel }; + + const isPassholderPreview = d.events?.some((e) => + e.extEventName.toLowerCase().includes("passholder preview") + ) ?? false; + + const isBuyout = item?.isBuyout ?? false; + + // Buyout days are private events — treat as closed unless it's a passholder preview + const isOpen = !d.isParkClosed && hoursLabel !== undefined && (!isBuyout || isPassholderPreview); + const specialType: DayResult["specialType"] = isPassholderPreview ? "passholder_preview" : undefined; + + return { date, isOpen, hoursLabel: isOpen ? hoursLabel : undefined, specialType }; }); } diff --git a/scripts/debug.ts b/scripts/debug.ts index 4e46445..5c45ed3 100644 --- a/scripts/debug.ts +++ b/scripts/debug.ts @@ -94,28 +94,37 @@ async function main() { // ── Parsed result ────────────────────────────────────────────────────────── const operating = - dayData.operatings?.find((o) => o.operatingTypeName === "Park") ?? + dayData.operatings?.find((o: { operatingTypeName: string }) => o.operatingTypeName === "Park") ?? dayData.operatings?.[0]; const item = operating?.items?.[0]; const hoursLabel = item?.timeFrom && item?.timeTo ? `${fmt24(item.timeFrom)} – ${fmt24(item.timeTo)}` : undefined; - const isOpen = !dayData.isParkClosed && hoursLabel !== undefined; + const isBuyout = item?.isBuyout ?? false; + const isPassholderPreview = dayData.events?.some((e: { extEventName: string }) => + e.extEventName.toLowerCase().includes("passholder preview") + ) ?? false; + const isOpen = !dayData.isParkClosed && hoursLabel !== undefined && (!isBuyout || isPassholderPreview); + const specialType = isPassholderPreview ? "passholder_preview" : null; out(""); out("── Parsed result ────────────────────────────────────────────"); - out(` isParkClosed : ${dayData.isParkClosed}`); - out(` operatings : ${dayData.operatings?.length ?? 0} entr${dayData.operatings?.length === 1 ? "y" : "ies"}`); + out(` isParkClosed : ${dayData.isParkClosed}`); + out(` events : ${dayData.events?.length ?? 0} (${dayData.events?.map((e: { extEventName: string }) => e.extEventName).join(", ") || "none"})`); + out(` operatings : ${dayData.operatings?.length ?? 0} entr${dayData.operatings?.length === 1 ? "y" : "ies"}`); if (operating) { - out(` selected : "${operating.operatingTypeName}" (${operating.items?.length ?? 0} item(s))`); + out(` selected : "${operating.operatingTypeName}" (${operating.items?.length ?? 0} item(s))`); if (item) { - out(` timeFrom : ${item.timeFrom} → ${fmt24(item.timeFrom)}`); - out(` timeTo : ${item.timeTo} → ${fmt24(item.timeTo)}`); + out(` timeFrom : ${item.timeFrom} → ${fmt24(item.timeFrom)}`); + out(` timeTo : ${item.timeTo} → ${fmt24(item.timeTo)}`); + out(` isBuyout : ${isBuyout}`); } } - out(` hoursLabel : ${hoursLabel ?? "(none)"}`); - out(` isOpen : ${isOpen}`); + out(` isPassholderPreview : ${isPassholderPreview}`); + out(` hoursLabel : ${hoursLabel ?? "(none)"}`); + out(` isOpen : ${isOpen}`); + out(` specialType : ${specialType ?? "(none)"}`) // ── Write to file ────────────────────────────────────────────────────────── const debugDir = path.join(process.cwd(), "debug"); diff --git a/scripts/scrape.ts b/scripts/scrape.ts index 571b849..604cee9 100644 --- a/scripts/scrape.ts +++ b/scripts/scrape.ts @@ -65,7 +65,7 @@ async function main() { try { const days = await scrapeMonth(apiId, YEAR, month); db.transaction(() => { - for (const d of days) upsertDay(db, park.id, d.date, d.isOpen, d.hoursLabel); + for (const d of days) upsertDay(db, park.id, d.date, d.isOpen, d.hoursLabel, d.specialType); })(); openDays += days.filter((d) => d.isOpen).length; fetched++;