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>
509 lines
16 KiB
TypeScript
509 lines
16 KiB
TypeScript
import Link from "next/link";
|
|
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 }>;
|
|
searchParams: Promise<{ month?: string }>;
|
|
}
|
|
|
|
function parseMonthParam(param: string | undefined): { year: number; month: number } {
|
|
if (param && /^\d{4}-\d{2}$/.test(param)) {
|
|
const [y, m] = param.split("-").map(Number);
|
|
if (y >= 2020 && y <= 2030 && m >= 1 && m <= 12) {
|
|
return { year: y, month: m };
|
|
}
|
|
}
|
|
const now = new Date();
|
|
return { year: now.getFullYear(), month: now.getMonth() + 1 };
|
|
}
|
|
|
|
export default async function ParkPage({ params, searchParams }: PageProps) {
|
|
const { id } = await params;
|
|
const { month: monthParam } = await searchParams;
|
|
|
|
const park = PARK_MAP.get(id);
|
|
if (!park) notFound();
|
|
|
|
const today = new Date().toISOString().slice(0, 10);
|
|
const { year, month } = parseMonthParam(monthParam);
|
|
|
|
const db = openDb();
|
|
const monthData = getParkMonthData(db, id, year, month);
|
|
const apiId = getApiId(db, id);
|
|
db.close();
|
|
|
|
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 ─────────────────────────────────────────────────────────── */}
|
|
<header style={{
|
|
position: "sticky",
|
|
top: 0,
|
|
zIndex: 20,
|
|
background: "var(--color-bg)",
|
|
borderBottom: "1px solid var(--color-border)",
|
|
padding: "12px 24px",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 16,
|
|
}}>
|
|
<Link href="/" style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 6,
|
|
fontSize: "0.8rem",
|
|
color: "var(--color-text-muted)",
|
|
textDecoration: "none",
|
|
transition: "color 120ms ease",
|
|
}}
|
|
className="park-name-link"
|
|
>
|
|
← Calendar
|
|
</Link>
|
|
<div style={{ width: 1, height: 16, background: "var(--color-border)" }} />
|
|
<span style={{ fontSize: "0.9rem", fontWeight: 600, color: "var(--color-text)", letterSpacing: "-0.01em" }}>
|
|
{park.name}
|
|
</span>
|
|
<span style={{ fontSize: "0.75rem", color: "var(--color-text-muted)" }}>
|
|
{park.location.city}, {park.location.state}
|
|
</span>
|
|
</header>
|
|
|
|
<main style={{ padding: "24px", maxWidth: 960, margin: "0 auto", display: "flex", flexDirection: "column", gap: 40 }}>
|
|
|
|
{/* ── Month Calendar ───────────────────────────────────────────────── */}
|
|
<section>
|
|
<ParkMonthCalendar
|
|
parkId={id}
|
|
year={year}
|
|
month={month}
|
|
monthData={monthData}
|
|
today={today}
|
|
/>
|
|
</section>
|
|
|
|
{/* ── Ride Status ─────────────────────────────────────────────────── */}
|
|
<section>
|
|
<SectionHeading>
|
|
Rides
|
|
{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>
|
|
|
|
{liveRides ? (
|
|
<LiveRideList
|
|
liveRides={liveRides}
|
|
parkOpenToday={!!parkOpenToday}
|
|
/>
|
|
) : (
|
|
<RideList
|
|
ridesResult={ridesResult}
|
|
parkOpenToday={!!parkOpenToday}
|
|
apiIdMissing={apiId === null && !queueTimesId}
|
|
/>
|
|
)}
|
|
</section>
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
|
|
function formatShortDate(iso: string): string {
|
|
return new Date(iso + "T00:00:00").toLocaleDateString("en-US", {
|
|
weekday: "short", month: "short", day: "numeric",
|
|
});
|
|
}
|
|
|
|
// ── Sub-components ─────────────────────────────────────────────────────────
|
|
|
|
function SectionHeading({ children }: { children: React.ReactNode }) {
|
|
return (
|
|
<div style={{
|
|
display: "flex",
|
|
alignItems: "baseline",
|
|
gap: 0,
|
|
marginBottom: 14,
|
|
paddingBottom: 10,
|
|
borderBottom: "1px solid var(--color-border)",
|
|
}}>
|
|
<h2 style={{
|
|
fontSize: "0.85rem",
|
|
fontWeight: 700,
|
|
color: "var(--color-text)",
|
|
letterSpacing: "0.04em",
|
|
textTransform: "uppercase",
|
|
margin: 0,
|
|
}}>
|
|
{children}
|
|
</h2>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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,
|
|
apiIdMissing,
|
|
}: {
|
|
ridesResult: RidesFetchResult | null;
|
|
parkOpenToday: boolean;
|
|
apiIdMissing: boolean;
|
|
}) {
|
|
if (apiIdMissing) {
|
|
return (
|
|
<Callout>
|
|
Park API ID not discovered yet. Run{" "}
|
|
<code style={{ background: "var(--color-surface-2)", padding: "1px 5px", borderRadius: 3, fontSize: "0.8em" }}>
|
|
npm run discover
|
|
</code>{" "}
|
|
to enable ride data.
|
|
</Callout>
|
|
);
|
|
}
|
|
|
|
if (!parkOpenToday) {
|
|
return <Callout>Park is closed today — no ride schedule available.</Callout>;
|
|
}
|
|
|
|
if (!ridesResult || ridesResult.rides.length === 0) {
|
|
return <Callout>Ride schedule is not yet available from the API.</Callout>;
|
|
}
|
|
|
|
const { rides, isExact, dataDate, parkHoursLabel } = ridesResult;
|
|
const openRides = rides.filter((r) => r.isOpen);
|
|
const closedRides = rides.filter((r) => !r.isOpen);
|
|
|
|
return (
|
|
<div>
|
|
{/* Summary badge row */}
|
|
<div style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 16, flexWrap: "wrap" }}>
|
|
<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>
|
|
{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 / unscheduled
|
|
</div>
|
|
)}
|
|
{!isExact && (
|
|
<span style={{ fontSize: "0.7rem", color: "var(--color-text-dim)" }}>
|
|
Showing {formatShortDate(dataDate)} — live schedule updates daily
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Two-column grid */}
|
|
<div style={{
|
|
display: "grid",
|
|
gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))",
|
|
gap: 6,
|
|
}}>
|
|
{openRides.map((ride) => <RideRow key={ride.name} ride={ride} parkHoursLabel={parkHoursLabel} />)}
|
|
{closedRides.map((ride) => <RideRow key={ride.name} ride={ride} parkHoursLabel={parkHoursLabel} />)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function RideRow({ ride, parkHoursLabel }: { ride: RideStatus; parkHoursLabel?: string }) {
|
|
const showHours = ride.isOpen && ride.hoursLabel && ride.hoursLabel !== parkHoursLabel;
|
|
|
|
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>
|
|
{showHours && (
|
|
<span style={{
|
|
fontSize: "0.72rem",
|
|
color: "var(--color-open-hours)",
|
|
fontWeight: 500,
|
|
flexShrink: 0,
|
|
whiteSpace: "nowrap",
|
|
}}>
|
|
{ride.hoursLabel}
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Callout({ children }: { children: React.ReactNode }) {
|
|
return (
|
|
<div style={{
|
|
padding: "14px 18px",
|
|
background: "var(--color-surface)",
|
|
border: "1px solid var(--color-border)",
|
|
borderRadius: 8,
|
|
fontSize: "0.82rem",
|
|
color: "var(--color-text-muted)",
|
|
lineHeight: 1.5,
|
|
}}>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|