feat: add live ride status via Queue-Times.com API
All checks were successful
Build and Deploy / Build & Push (push) Successful in 2m51s
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:
@@ -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 (
|
||||
<div style={{ minHeight: "100vh", background: "var(--color-bg)" }}>
|
||||
{/* ── Header ─────────────────────────────────────────────────────────── */}
|
||||
@@ -101,18 +112,31 @@ export default async function ParkPage({ params, searchParams }: PageProps) {
|
||||
<section>
|
||||
<SectionHeading>
|
||||
Rides
|
||||
<span style={{ fontSize: "0.72rem", fontWeight: 400, color: "var(--color-text-muted)", marginLeft: 8 }}>
|
||||
{ridesResult && !ridesResult.isExact
|
||||
? formatShortDate(ridesResult.dataDate)
|
||||
: "Today"}
|
||||
</span>
|
||||
{liveRides ? (
|
||||
<LiveBadge />
|
||||
) : ridesResult && !ridesResult.isExact ? (
|
||||
<span style={{ fontSize: "0.72rem", fontWeight: 400, color: "var(--color-text-muted)", marginLeft: 8 }}>
|
||||
{formatShortDate(ridesResult.dataDate)}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ fontSize: "0.72rem", fontWeight: 400, color: "var(--color-text-muted)", marginLeft: 8 }}>
|
||||
Today
|
||||
</span>
|
||||
)}
|
||||
</SectionHeading>
|
||||
|
||||
<RideList
|
||||
ridesResult={ridesResult}
|
||||
parkOpenToday={!!parkOpenToday}
|
||||
apiIdMissing={apiId === null}
|
||||
/>
|
||||
{liveRides ? (
|
||||
<LiveRideList
|
||||
liveRides={liveRides}
|
||||
parkOpenToday={!!parkOpenToday}
|
||||
/>
|
||||
) : (
|
||||
<RideList
|
||||
ridesResult={ridesResult}
|
||||
parkOpenToday={!!parkOpenToday}
|
||||
apiIdMissing={apiId === null && !queueTimesId}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
</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({
|
||||
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 (
|
||||
|
||||
Reference in New Issue
Block a user