diff --git a/README.md b/README.md index 3f55bec..0ab213e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Six Flags Super Calendar -A week-by-week calendar showing operating hours for all Six Flags Entertainment Group theme parks — including the former Cedar Fair parks. Data is scraped from the Six Flags internal API and stored locally in SQLite. Click any park to see its full month calendar and today's ride status. +A week-by-week calendar showing operating hours for all Six Flags Entertainment Group theme parks — including the former Cedar Fair parks. Data is scraped from the Six Flags internal API and stored locally in SQLite. Click any park to see its full month calendar and live ride status with current wait times. ## Parks @@ -21,6 +21,17 @@ A week-by-week calendar showing operating hours for all Six Flags Entertainment - **SQLite** via `better-sqlite3` — persisted in `/app/data/parks.db` - **Playwright** — one-time headless browser run to discover each park's internal API ID - **Six Flags CloudFront API** — `https://d18car1k0ff81h.cloudfront.net/operating-hours/park/{id}?date=YYYYMM` +- **Queue-Times.com API** — live ride open/closed status and wait times, updated every 5 minutes + +## Ride Status + +The park detail page shows ride open/closed status using a two-tier approach: + +1. **Live data (Queue-Times.com)** — when a park is operating, ride status and wait times are fetched from the [Queue-Times.com API](https://queue-times.com/en-US/pages/api) and cached for 5 minutes. All 24 parks are mapped. Displays a **Live** badge with per-ride wait times. + +2. **Schedule fallback (Six Flags API)** — the Six Flags operating-hours API drops the current day from its response once a park opens. When Queue-Times data is unavailable, the app falls back to the nearest upcoming date from the Six Flags schedule API as an approximation. + +--- ## Local Development diff --git a/app/park/[id]/page.tsx b/app/park/[id]/page.tsx index 7f42603..b16e35b 100644 --- a/app/park/[id]/page.tsx +++ b/app/park/[id]/page.tsx @@ -3,8 +3,11 @@ import { notFound } from "next/navigation"; 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 { QUEUE_TIMES_IDS } from "@/lib/queue-times-map"; import { ParkMonthCalendar } from "@/components/ParkMonthCalendar"; import type { RideStatus, RidesFetchResult } from "@/lib/scrapers/sixflags"; +import type { LiveRidesResult, LiveRide } from "@/lib/scrapers/queuetimes"; interface PageProps { params: Promise<{ id: string }>; @@ -37,17 +40,25 @@ export default async function ParkPage({ params, searchParams }: PageProps) { const apiId = getApiId(db, id); db.close(); - // Fetch live ride data — cached 1h via Next.js ISR. - // Note: the API drops today's date from its response (only returns future dates), - // so scrapeRidesForDay may fall back to the nearest upcoming date. - let ridesResult: RidesFetchResult | null = null; - if (apiId !== null) { - ridesResult = await scrapeRidesForDay(apiId, today); - } - const todayData = monthData[today]; const parkOpenToday = todayData?.isOpen && todayData?.hoursLabel; + // ── Ride data: try live Queue-Times first, fall back to schedule ────────── + const queueTimesId = QUEUE_TIMES_IDS[id]; + let liveRides: LiveRidesResult | null = null; + let ridesResult: RidesFetchResult | null = null; + + if (queueTimesId) { + liveRides = await fetchLiveRides(queueTimesId); + } + + // Only hit the schedule API as a fallback when 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); + } + return (
{/* ── Header ─────────────────────────────────────────────────────────── */} @@ -101,18 +112,31 @@ export default async function ParkPage({ params, searchParams }: PageProps) {
Rides - - {ridesResult && !ridesResult.isExact - ? formatShortDate(ridesResult.dataDate) - : "Today"} - + {liveRides ? ( + + ) : ridesResult && !ridesResult.isExact ? ( + + {formatShortDate(ridesResult.dataDate)} + + ) : ( + + Today + + )} - + {liveRides ? ( + + ) : ( + + )}
@@ -153,6 +177,190 @@ function SectionHeading({ children }: { children: React.ReactNode }) { ); } +function LiveBadge() { + return ( + + + Live + + ); +} + +// ── Live ride list (Queue-Times data) ────────────────────────────────────── + +function LiveRideList({ + liveRides, + parkOpenToday, +}: { + liveRides: LiveRidesResult; + parkOpenToday: boolean; +}) { + const { rides } = liveRides; + const openRides = rides.filter((r) => r.isOpen); + const closedRides = rides.filter((r) => !r.isOpen); + const anyOpen = openRides.length > 0; + + return ( +
+ {/* Summary badge row */} +
+ {anyOpen ? ( +
+ {openRides.length} open +
+ ) : ( +
+ {parkOpenToday ? "Not open yet — check back soon" : "No rides open"} +
+ )} + {anyOpen && closedRides.length > 0 && ( +
+ {closedRides.length} closed / down +
+ )} +
+ + {/* Two-column grid */} +
+ {openRides.map((ride) => )} + {closedRides.map((ride) => )} +
+ + {/* Attribution — required by Queue-Times terms */} +
+ Powered by{" "} + + Queue-Times.com + + {" "}· Updates every 5 minutes +
+
+ ); +} + +function LiveRideRow({ ride }: { ride: LiveRide }) { + const showWait = ride.isOpen && ride.waitMinutes > 0; + + return ( +
+
+ + + {ride.name} + +
+ {showWait && ( + + {ride.waitMinutes} min + + )} + {ride.isOpen && !showWait && ( + + walk-on + + )} +
+ ); +} + +// ── Schedule ride list (Six Flags operating-hours API fallback) ──────────── + function RideList({ ridesResult, parkOpenToday, @@ -235,9 +443,6 @@ function RideList({ } function RideRow({ ride, parkHoursLabel }: { ride: RideStatus; parkHoursLabel?: string }) { - // Only show the ride's hours when they differ from the park's overall hours. - // This avoids repeating "10am – 6pm" on every single row when that's the - // default — but surfaces exceptions like "11am – 4pm" for Safari tours, etc. const showHours = ride.isOpen && ride.hoursLabel && ride.hoursLabel !== parkHoursLabel; return ( diff --git a/lib/queue-times-map.ts b/lib/queue-times-map.ts new file mode 100644 index 0000000..b3257db --- /dev/null +++ b/lib/queue-times-map.ts @@ -0,0 +1,35 @@ +/** + * Maps our internal park IDs to Queue-Times.com park IDs. + * + * API: https://queue-times.com/parks/{id}/queue_times.json + * Attribution required: "Powered by Queue-Times.com" + * See: https://queue-times.com/en-US/pages/api + */ +export const QUEUE_TIMES_IDS: Record = { + // Six Flags branded parks + greatadventure: 37, + magicmountain: 32, + greatamerica: 38, + overgeorgia: 35, + overtexas: 34, + stlouis: 36, + fiestatexas: 39, + newengland: 43, + discoverykingdom: 33, + mexico: 47, + greatescape: 45, + darienlake: 281, + // Former Cedar Fair parks + cedarpoint: 50, + knotts: 61, + canadaswonderland: 58, + carowinds: 59, + kingsdominion: 62, + kingsisland: 60, + valleyfair: 68, + worldsoffun: 63, + miadventure: 70, + dorneypark: 69, + cagreatamerica: 57, + frontiercity: 282, +}; diff --git a/lib/scrapers/queuetimes.ts b/lib/scrapers/queuetimes.ts new file mode 100644 index 0000000..c9c3201 --- /dev/null +++ b/lib/scrapers/queuetimes.ts @@ -0,0 +1,115 @@ +/** + * Queue-Times.com live ride status scraper. + * + * API: https://queue-times.com/parks/{id}/queue_times.json + * Updates every 5 minutes while the park is operating. + * Attribution required per their terms: "Powered by Queue-Times.com" + * See: https://queue-times.com/en-US/pages/api + */ + +const BASE = "https://queue-times.com/parks"; + +const HEADERS = { + "User-Agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", + Accept: "application/json", +}; + +export interface LiveRide { + name: string; + isOpen: boolean; + waitMinutes: number; + lastUpdated: string; // ISO 8601 +} + +export interface LiveRidesResult { + rides: LiveRide[]; + /** ISO timestamp of when we fetched the data */ + fetchedAt: string; +} + +interface QTRide { + id: number; + name: string; + is_open: boolean; + wait_time: number; + last_updated: string; +} + +interface QTLand { + id: number; + name: string; + rides: QTRide[]; +} + +interface QTResponse { + lands: QTLand[]; + rides: QTRide[]; // top-level rides (usually empty, rides live in lands) +} + +/** + * Fetch live ride open/closed status and wait times for a park. + * + * Returns null when: + * - The park has no Queue-Times mapping + * - The request fails + * - The response contains no rides + * + * Pass revalidate (seconds) to control Next.js ISR cache lifetime. + * Defaults to 300s (5 min) to match Queue-Times update frequency. + */ +export async function fetchLiveRides( + queueTimesId: number, + revalidate = 300, +): Promise { + const url = `${BASE}/${queueTimesId}/queue_times.json`; + try { + const res = await fetch(url, { + headers: HEADERS, + next: { revalidate }, + } as RequestInit & { next: { revalidate: number } }); + + if (!res.ok) return null; + + const json = (await res.json()) as QTResponse; + + const rides: LiveRide[] = []; + + // Rides are nested inside lands + for (const land of json.lands ?? []) { + for (const r of land.rides ?? []) { + if (!r.name) continue; + rides.push({ + name: r.name, + isOpen: r.is_open, + waitMinutes: r.wait_time ?? 0, + lastUpdated: r.last_updated, + }); + } + } + + // Also capture any top-level rides (rare but possible) + for (const r of json.rides ?? []) { + if (!r.name) continue; + rides.push({ + name: r.name, + isOpen: r.is_open, + waitMinutes: r.wait_time ?? 0, + lastUpdated: r.last_updated, + }); + } + + if (rides.length === 0) return null; + + // Open rides first, then alphabetical within each group + rides.sort((a, b) => { + if (a.isOpen !== b.isOpen) return a.isOpen ? -1 : 1; + return a.name.localeCompare(b.name); + }); + + return { rides, fetchedAt: new Date().toISOString() }; + } catch { + return null; + } +}