All checks were successful
Build and Deploy / Build & Push (push) Successful in 4m22s
- isWithinOperatingWindow now accepts an IANA timezone and reads the current time in the park's local timezone via Intl.DateTimeFormat, fixing false positives when the server runs in UTC but parks store hours in local time (e.g. Pacific parks showing open at 6:50 AM EDT) - Remove the 1-hour pre-open buffer so parks are not marked open before their doors actually open; retain the 1-hour post-close grace period - Add getTimezoneAbbr() helper to derive the short tz label (EDT, PDT…) - All hours labels now display with the local timezone abbreviation (e.g. "10am – 6pm PDT") in WeekCalendar, ParkCard, and ParkMonthCalendar Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
378 lines
12 KiB
TypeScript
378 lines
12 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 { readParkMeta, getCoasterSet } from "@/lib/park-meta";
|
|
import { ParkMonthCalendar } from "@/components/ParkMonthCalendar";
|
|
import { LiveRidePanel } from "@/components/LiveRidePanel";
|
|
import type { RideStatus, RidesFetchResult } from "@/lib/scrapers/sixflags";
|
|
import type { LiveRidesResult } from "@/lib/scrapers/queuetimes"; // used as prop type below
|
|
import { getTodayLocal, isWithinOperatingWindow } from "@/lib/env";
|
|
|
|
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 [y, m] = getTodayLocal().split("-").map(Number);
|
|
return { year: y, month: m };
|
|
}
|
|
|
|
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 = getTodayLocal();
|
|
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];
|
|
const parkMeta = readParkMeta();
|
|
const coasterSet = getCoasterSet(id, parkMeta);
|
|
|
|
let liveRides: LiveRidesResult | null = null;
|
|
let ridesResult: RidesFetchResult | null = null;
|
|
|
|
// Determine if we're within the 1h-before-open to 1h-after-close window.
|
|
const withinWindow = todayData?.hoursLabel
|
|
? isWithinOperatingWindow(todayData.hoursLabel, park.timezone)
|
|
: false;
|
|
|
|
if (queueTimesId) {
|
|
const raw = await fetchLiveRides(queueTimesId, coasterSet);
|
|
if (raw) {
|
|
// Outside the window: show the ride list but force all rides closed
|
|
liveRides = withinWindow
|
|
? raw
|
|
: {
|
|
...raw,
|
|
rides: raw.rides.map((r) => ({ ...r, isOpen: false, waitMinutes: 0 })),
|
|
};
|
|
}
|
|
}
|
|
|
|
// 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 32px", maxWidth: 1280, margin: "0 auto", display: "flex", flexDirection: "column", gap: 40 }}>
|
|
|
|
{/* ── Month Calendar ───────────────────────────────────────────────── */}
|
|
<section>
|
|
<ParkMonthCalendar
|
|
parkId={id}
|
|
year={year}
|
|
month={month}
|
|
monthData={monthData}
|
|
today={today}
|
|
timezone={park.timezone}
|
|
/>
|
|
</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 ? (
|
|
<LiveRidePanel
|
|
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>
|
|
);
|
|
}
|
|
|
|
// ── 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 title={ride.name} 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>
|
|
);
|
|
}
|