From f0faff412c35b73c6f9e1207a7c5aea9bf8380c3 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Sun, 5 Apr 2026 16:54:06 -0400 Subject: [PATCH] feat: use dateless Six Flags API endpoint for live today data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The API without a date param returns today's operating data directly, invalidating the previous assumption that today's date was always missing. - Add fetchToday(apiId, revalidate?) to sixflags.ts — calls the dateless endpoint with optional ISR cache - Extract parseApiDay() helper shared by scrapeMonth and fetchToday - Update upsertDay WHERE clause: >= date('now') so today can be updated (was > date('now'), which froze today after first write) - scrape.ts: add a today-scrape pass after the monthly loop so each run always writes fresh today data to the DB - app/page.tsx: fetch live today data for all parks (5-min ISR) and merge into the data map before computing open/closing/weatherDelay status - app/park/[id]/page.tsx: prefer live today data from API for todayData so weather delays and hour changes surface within 5 minutes - scrapeRidesForDay: update comment only — role unchanged (QT fallback) Co-Authored-By: Claude Sonnet 4.6 --- app/page.tsx | 29 ++++++++++++++++- app/park/[id]/page.tsx | 13 +++++--- lib/db.ts | 10 +++--- lib/scrapers/sixflags.ts | 70 +++++++++++++++++++++++----------------- scripts/scrape.ts | 21 +++++++++++- 5 files changed, 102 insertions(+), 41 deletions(-) diff --git a/app/page.tsx b/app/page.tsx index 6af2169..d94cae3 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,10 +1,12 @@ import { HomePageClient } from "@/components/HomePageClient"; import { PARKS } from "@/lib/parks"; -import { openDb, getDateRange } from "@/lib/db"; +import { openDb, getDateRange, getApiId } from "@/lib/db"; import { getTodayLocal, isWithinOperatingWindow, getOperatingStatus } from "@/lib/env"; import { fetchLiveRides } from "@/lib/scrapers/queuetimes"; +import { fetchToday } from "@/lib/scrapers/sixflags"; import { QUEUE_TIMES_IDS } from "@/lib/queue-times-map"; import { readParkMeta, getCoasterSet } from "@/lib/park-meta"; +import type { DayData } from "@/lib/db"; interface PageProps { searchParams: Promise<{ week?: string }>; @@ -49,6 +51,31 @@ export default async function HomePage({ searchParams }: PageProps) { const db = openDb(); const data = getDateRange(db, weekStart, endDate); + + // Merge live today data from the Six Flags API (dateless endpoint, 5-min ISR cache). + // This ensures weather delays, early closures, and hour changes surface within 5 minutes + // without waiting for the next scheduled scrape. Only fetched when viewing the current week. + if (weekDates.includes(today)) { + const todayResults = await Promise.all( + PARKS.map(async (p) => { + const apiId = getApiId(db, p.id); + if (!apiId) return null; + const live = await fetchToday(apiId, 300); // 5-min ISR cache + return live ? { parkId: p.id, live } : null; + }) + ); + for (const result of todayResults) { + if (!result) continue; + const { parkId, live } = result; + if (!data[parkId]) data[parkId] = {}; + data[parkId][today] = { + isOpen: live.isOpen, + hoursLabel: live.hoursLabel ?? null, + specialType: live.specialType ?? null, + } satisfies DayData; + } + } + db.close(); const scrapedCount = Object.values(data).reduce( diff --git a/app/park/[id]/page.tsx b/app/park/[id]/page.tsx index 9662772..6cd4e13 100644 --- a/app/park/[id]/page.tsx +++ b/app/park/[id]/page.tsx @@ -5,6 +5,7 @@ import { PARK_MAP } from "@/lib/parks"; import { openDb, getParkMonthData, getApiId } from "@/lib/db"; import { scrapeRidesForDay } from "@/lib/scrapers/sixflags"; import { fetchLiveRides } from "@/lib/scrapers/queuetimes"; +import { fetchToday } from "@/lib/scrapers/sixflags"; import { QUEUE_TIMES_IDS } from "@/lib/queue-times-map"; import { readParkMeta, getCoasterSet } from "@/lib/park-meta"; import { ParkMonthCalendar } from "@/components/ParkMonthCalendar"; @@ -44,7 +45,13 @@ export default async function ParkPage({ params, searchParams }: PageProps) { const apiId = getApiId(db, id); db.close(); - const todayData = monthData[today]; + // Prefer live today data from the Six Flags API (5-min ISR cache) so that + // weather delays and hour changes surface immediately rather than showing + // stale DB values. Fall back to DB if the API call fails. + const liveToday = apiId !== null ? await fetchToday(apiId, 300).catch(() => null) : null; + const todayData = liveToday + ? { isOpen: liveToday.isOpen, hoursLabel: liveToday.hoursLabel ?? null, specialType: liveToday.specialType ?? null } + : monthData[today]; const parkOpenToday = todayData?.isOpen && todayData?.hoursLabel; // ── Ride data: try live Queue-Times first, fall back to schedule ────────── @@ -80,10 +87,8 @@ export default async function ParkPage({ params, searchParams }: PageProps) { liveRides.rides.length > 0 && liveRides.rides.every((r) => !r.isOpen); - // Only hit the schedule API as a fallback when live data is unavailable + // Only hit the schedule API as a fallback when Queue-Times live data is unavailable. if (!liveRides && apiId !== null) { - // Note: the API drops today's date from its response (only returns future dates), - // so scrapeRidesForDay may fall back to the nearest upcoming date. ridesResult = await scrapeRidesForDay(apiId, today); } diff --git a/lib/db.ts b/lib/db.ts index 32c59cd..b94846a 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -46,12 +46,10 @@ export function upsertDay( hoursLabel?: string, specialType?: string ) { - // Today and past dates: INSERT new rows freely, but NEVER overwrite existing records. - // Once an operating day begins the API drops that date from its response, so a - // re-scrape would incorrectly record the day as closed. The DB row written when - // the date was still in the future is the permanent truth for that day. + // Today and future dates: full upsert — hours can change (e.g. weather delays, + // early closures) and the dateless API endpoint now returns today's live data. // - // Future dates only: full upsert — hours can change and closures can be added. + // Past dates: INSERT-only — never overwrite once the day has passed. db.prepare(` INSERT INTO park_days (park_id, date, is_open, hours_label, special_type, scraped_at) VALUES (?, ?, ?, ?, ?, ?) @@ -60,7 +58,7 @@ export function upsertDay( hours_label = excluded.hours_label, special_type = excluded.special_type, scraped_at = excluded.scraped_at - WHERE park_days.date > date('now') + WHERE park_days.date >= date('now') `).run(parkId, date, isOpen ? 1 : 0, hoursLabel ?? null, specialType ?? null, new Date().toISOString()); } diff --git a/lib/scrapers/sixflags.ts b/lib/scrapers/sixflags.ts index 8d00a03..4a3c98f 100644 --- a/lib/scrapers/sixflags.ts +++ b/lib/scrapers/sixflags.ts @@ -166,13 +166,48 @@ function apiDateToIso(apiDate: string): string { return `${yyyy}-${mm}-${dd}`; } +/** Parse a single ApiDay into a DayResult. Shared by scrapeMonth and fetchToday. */ +function parseApiDay(d: ApiDay): DayResult { + const date = parseApiDate(d.date); + const operating = + d.operatings?.find((o) => o.operatingTypeName === "Park") ?? + d.operatings?.[0]; + const item = operating?.items?.[0]; + const hoursLabel = + item?.timeFrom && item?.timeTo + ? `${fmt24(item.timeFrom)} – ${fmt24(item.timeTo)}` + : undefined; + const isPassholderPreview = d.events?.some((e) => + e.extEventName.toLowerCase().includes("passholder preview") + ) ?? false; + const isBuyout = item?.isBuyout ?? false; + const isOpen = !d.isParkClosed && hoursLabel !== undefined && (!isBuyout || isPassholderPreview); + const specialType: DayResult["specialType"] = isPassholderPreview ? "passholder_preview" : undefined; + return { date, isOpen, hoursLabel: isOpen ? hoursLabel : undefined, specialType }; +} + /** - * Fetch ride operating status for a given date. + * Fetch today's operating data directly (no date param = API returns today). + * Pass `revalidate` (seconds) for Next.js ISR caching; omit for a fully fresh fetch. + */ +export async function fetchToday(apiId: number, revalidate?: number): Promise { + try { + const url = `${API_BASE}/${apiId}`; + const raw = await fetchApi(url, 0, 0, revalidate); + if (!raw.dates.length) return null; + return parseApiDay(raw.dates[0]); + } catch { + return null; + } +} + +/** + * Fetch ride operating status for a given date. Used as a fallback when + * Queue-Times live data is unavailable. * - * The Six Flags API drops dates that have already started (including today), - * returning only tomorrow onwards. When the requested date is missing, we fall - * back to the nearest available upcoming date in the same month's response so - * the UI can still show a useful (if approximate) schedule. + * The monthly API endpoint (`?date=YYYYMM`) may not include today; use + * `fetchToday(apiId)` to get today's park hours directly. The fallback + * chain here will find the nearest upcoming date if an exact match is missing. * * Returns null if no ride data could be found at all (API error, pre-season, * no venues in response). @@ -286,30 +321,7 @@ export async function scrapeMonth( const data = await fetchApi(url); - return data.dates.map((d): DayResult => { - const date = parseApiDate(d.date); - // Prefer the "Park" operating entry; fall back to first entry - const operating = - d.operatings?.find((o) => o.operatingTypeName === "Park") ?? - d.operatings?.[0]; - const item = operating?.items?.[0]; - const hoursLabel = - item?.timeFrom && item?.timeTo - ? `${fmt24(item.timeFrom)} – ${fmt24(item.timeTo)}` - : undefined; - - 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 }; - }); + return data.dates.map(parseApiDay); } /** diff --git a/scripts/scrape.ts b/scripts/scrape.ts index b3977d7..6c7ae6a 100644 --- a/scripts/scrape.ts +++ b/scripts/scrape.ts @@ -9,7 +9,7 @@ import { openDb, upsertDay, getApiId, isMonthScraped } from "../lib/db"; import { PARKS } from "../lib/parks"; -import { scrapeMonth, RateLimitError } from "../lib/scrapers/sixflags"; +import { scrapeMonth, fetchToday, RateLimitError } from "../lib/scrapers/sixflags"; import { readParkMeta, writeParkMeta, areCoastersStale } from "../lib/park-meta"; import { scrapeRcdbCoasters } from "../lib/scrapers/rcdb"; @@ -100,6 +100,25 @@ async function main() { console.log(`\n ${totalFetched} fetched ${totalSkipped} skipped ${totalErrors} errors`); if (totalErrors > 0) console.log(" Re-run to retry failed months."); + // ── Today scrape (always fresh — dateless endpoint returns current day) ──── + console.log("\n── Today's data ──"); + for (const park of ready) { + const apiId = getApiId(db, park.id)!; + process.stdout.write(` ${park.shortName.padEnd(22)} `); + try { + const today = await fetchToday(apiId); + if (today) { + upsertDay(db, park.id, today.date, today.isOpen, today.hoursLabel, today.specialType); + console.log(today.isOpen ? `open ${today.hoursLabel ?? ""}` : "closed"); + } else { + console.log("no data"); + } + } catch { + console.log("error"); + } + await sleep(500); + } + db.close(); // ── RCDB coaster scrape (30-day staleness) ────────────────────────────────