feat: add live ride status via Queue-Times.com API
All checks were successful
Build and Deploy / Build & Push (push) Successful in 2m51s

Park detail pages now show real-time ride open/closed status and wait
times sourced from Queue-Times.com (updates every 5 min) when a park
is operating. Falls back to the Six Flags schedule API for off-hours
or parks without a Queue-Times mapping.

- lib/queue-times-map.ts: maps all 24 park IDs to Queue-Times park IDs
- lib/scrapers/queuetimes.ts: fetches and parses queue_times.json with
  5-minute ISR cache; returns LiveRidesResult with isOpen + waitMinutes
- app/park/[id]/page.tsx: tries Queue-Times first; renders LiveRideList
  with Live badge and per-ride wait times; falls back to RideList for
  schedule data when live data is unavailable
- README: documents two-tier ride status approach

Attribution: Queue-Times.com (displayed in UI per their API terms)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-04 12:15:36 -04:00
parent ba8cd46e75
commit e7b72ff95b
4 changed files with 388 additions and 22 deletions

View File

@@ -1,6 +1,6 @@
# Six Flags Super Calendar # 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 ## 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` - **SQLite** via `better-sqlite3` — persisted in `/app/data/parks.db`
- **Playwright** — one-time headless browser run to discover each park's internal API ID - **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` - **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 ## Local Development

View File

@@ -3,8 +3,11 @@ import { notFound } from "next/navigation";
import { PARK_MAP } from "@/lib/parks"; import { PARK_MAP } from "@/lib/parks";
import { openDb, getParkMonthData, getApiId } from "@/lib/db"; import { openDb, getParkMonthData, getApiId } from "@/lib/db";
import { scrapeRidesForDay } from "@/lib/scrapers/sixflags"; 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 { ParkMonthCalendar } from "@/components/ParkMonthCalendar";
import type { RideStatus, RidesFetchResult } from "@/lib/scrapers/sixflags"; import type { RideStatus, RidesFetchResult } from "@/lib/scrapers/sixflags";
import type { LiveRidesResult, LiveRide } from "@/lib/scrapers/queuetimes";
interface PageProps { interface PageProps {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
@@ -37,17 +40,25 @@ export default async function ParkPage({ params, searchParams }: PageProps) {
const apiId = getApiId(db, id); const apiId = getApiId(db, id);
db.close(); 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 todayData = monthData[today];
const parkOpenToday = todayData?.isOpen && todayData?.hoursLabel; 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 ( return (
<div style={{ minHeight: "100vh", background: "var(--color-bg)" }}> <div style={{ minHeight: "100vh", background: "var(--color-bg)" }}>
{/* ── Header ─────────────────────────────────────────────────────────── */} {/* ── Header ─────────────────────────────────────────────────────────── */}
@@ -101,18 +112,31 @@ export default async function ParkPage({ params, searchParams }: PageProps) {
<section> <section>
<SectionHeading> <SectionHeading>
Rides Rides
{liveRides ? (
<LiveBadge />
) : ridesResult && !ridesResult.isExact ? (
<span style={{ fontSize: "0.72rem", fontWeight: 400, color: "var(--color-text-muted)", marginLeft: 8 }}> <span style={{ fontSize: "0.72rem", fontWeight: 400, color: "var(--color-text-muted)", marginLeft: 8 }}>
{ridesResult && !ridesResult.isExact {formatShortDate(ridesResult.dataDate)}
? formatShortDate(ridesResult.dataDate)
: "Today"}
</span> </span>
) : (
<span style={{ fontSize: "0.72rem", fontWeight: 400, color: "var(--color-text-muted)", marginLeft: 8 }}>
Today
</span>
)}
</SectionHeading> </SectionHeading>
{liveRides ? (
<LiveRideList
liveRides={liveRides}
parkOpenToday={!!parkOpenToday}
/>
) : (
<RideList <RideList
ridesResult={ridesResult} ridesResult={ridesResult}
parkOpenToday={!!parkOpenToday} parkOpenToday={!!parkOpenToday}
apiIdMissing={apiId === null} apiIdMissing={apiId === null && !queueTimesId}
/> />
)}
</section> </section>
</main> </main>
</div> </div>
@@ -153,6 +177,190 @@ function SectionHeading({ children }: { children: React.ReactNode }) {
); );
} }
function LiveBadge() {
return (
<span style={{
display: "inline-flex",
alignItems: "center",
gap: 5,
marginLeft: 10,
padding: "2px 8px",
borderRadius: 20,
background: "var(--color-open-bg)",
border: "1px solid var(--color-open-border)",
fontSize: "0.65rem",
fontWeight: 700,
letterSpacing: "0.06em",
textTransform: "uppercase",
color: "var(--color-open-text)",
verticalAlign: "middle",
}}>
<span style={{
width: 5,
height: 5,
borderRadius: "50%",
background: "var(--color-open-text)",
display: "inline-block",
}} />
Live
</span>
);
}
// ── 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 (
<div>
{/* Summary badge row */}
<div style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 16, flexWrap: "wrap" }}>
{anyOpen ? (
<div style={{
background: "var(--color-open-bg)",
border: "1px solid var(--color-open-border)",
borderRadius: 20,
padding: "4px 12px",
fontSize: "0.72rem",
fontWeight: 600,
color: "var(--color-open-hours)",
}}>
{openRides.length} open
</div>
) : (
<div style={{
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
borderRadius: 20,
padding: "4px 12px",
fontSize: "0.72rem",
fontWeight: 500,
color: "var(--color-text-muted)",
}}>
{parkOpenToday ? "Not open yet — check back soon" : "No rides open"}
</div>
)}
{anyOpen && closedRides.length > 0 && (
<div style={{
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
borderRadius: 20,
padding: "4px 12px",
fontSize: "0.72rem",
fontWeight: 500,
color: "var(--color-text-muted)",
}}>
{closedRides.length} closed / down
</div>
)}
</div>
{/* Two-column grid */}
<div style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))",
gap: 6,
}}>
{openRides.map((ride) => <LiveRideRow key={ride.name} ride={ride} />)}
{closedRides.map((ride) => <LiveRideRow key={ride.name} ride={ride} />)}
</div>
{/* Attribution — required by Queue-Times terms */}
<div style={{
marginTop: 20,
fontSize: "0.68rem",
color: "var(--color-text-dim)",
display: "flex",
alignItems: "center",
gap: 4,
}}>
Powered by{" "}
<a
href="https://queue-times.com"
target="_blank"
rel="noopener noreferrer"
style={{ color: "var(--color-text-muted)", textDecoration: "underline" }}
>
Queue-Times.com
</a>
{" "}· Updates every 5 minutes
</div>
</div>
);
}
function LiveRideRow({ ride }: { ride: LiveRide }) {
const showWait = ride.isOpen && ride.waitMinutes > 0;
return (
<div style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 10,
padding: "8px 12px",
background: "var(--color-surface)",
border: `1px solid ${ride.isOpen ? "var(--color-open-border)" : "var(--color-border)"}`,
borderRadius: 8,
opacity: ride.isOpen ? 1 : 0.6,
}}>
<div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}>
<span style={{
width: 7,
height: 7,
borderRadius: "50%",
background: ride.isOpen ? "var(--color-open-text)" : "var(--color-text-dim)",
flexShrink: 0,
}} />
<span style={{
fontSize: "0.8rem",
color: ride.isOpen ? "var(--color-text)" : "var(--color-text-muted)",
fontWeight: ride.isOpen ? 500 : 400,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}>
{ride.name}
</span>
</div>
{showWait && (
<span style={{
fontSize: "0.72rem",
color: "var(--color-open-hours)",
fontWeight: 600,
flexShrink: 0,
whiteSpace: "nowrap",
}}>
{ride.waitMinutes} min
</span>
)}
{ride.isOpen && !showWait && (
<span style={{
fontSize: "0.68rem",
color: "var(--color-open-text)",
fontWeight: 500,
flexShrink: 0,
opacity: 0.7,
}}>
walk-on
</span>
)}
</div>
);
}
// ── Schedule ride list (Six Flags operating-hours API fallback) ────────────
function RideList({ function RideList({
ridesResult, ridesResult,
parkOpenToday, parkOpenToday,
@@ -235,9 +443,6 @@ function RideList({
} }
function RideRow({ ride, parkHoursLabel }: { ride: RideStatus; parkHoursLabel?: string }) { 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; const showHours = ride.isOpen && ride.hoursLabel && ride.hoursLabel !== parkHoursLabel;
return ( return (

35
lib/queue-times-map.ts Normal file
View File

@@ -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<string, number> = {
// 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,
};

115
lib/scrapers/queuetimes.ts Normal file
View File

@@ -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<LiveRidesResult | null> {
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;
}
}