Files
SixFlagsSuperCalendar/app/park/[id]/page.tsx
Josh Wright f0faff412c
All checks were successful
Build and Deploy / Build & Push (push) Successful in 17s
feat: use dateless Six Flags API endpoint for live today data
The API without a date param returns today's operating data directly,
invalidating the previous assumption that today's date was always missing.

- Add fetchToday(apiId, revalidate?) to sixflags.ts — calls the dateless
  endpoint with optional ISR cache
- Extract parseApiDay() helper shared by scrapeMonth and fetchToday
- Update upsertDay WHERE clause: >= date('now') so today can be updated
  (was > date('now'), which froze today after first write)
- scrape.ts: add a today-scrape pass after the monthly loop so each run
  always writes fresh today data to the DB
- app/page.tsx: fetch live today data for all parks (5-min ISR) and merge
  into the data map before computing open/closing/weatherDelay status
- app/park/[id]/page.tsx: prefer live today data from API for todayData
  so weather delays and hour changes surface within 5 minutes
- scrapeRidesForDay: update comment only — role unchanged (QT fallback)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 16:54:06 -04:00

401 lines
13 KiB
TypeScript

import Link from "next/link";
import { BackToCalendarLink } from "@/components/BackToCalendarLink";
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 { fetchToday } from "@/lib/scrapers/sixflags";
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();
// Prefer live today data from the Six Flags API (5-min ISR cache) so that
// weather delays and hour changes surface immediately rather than showing
// stale DB values. Fall back to DB if the API call fails.
const liveToday = apiId !== null ? await fetchToday(apiId, 300).catch(() => null) : null;
const todayData = liveToday
? { isOpen: liveToday.isOpen, hoursLabel: liveToday.hoursLabel ?? null, specialType: liveToday.specialType ?? null }
: 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 })),
};
}
}
// Weather delay: park is within operating hours but queue-times shows 0 open rides
const isWeatherDelay =
withinWindow &&
liveRides !== null &&
liveRides.rides.length > 0 &&
liveRides.rides.every((r) => !r.isOpen);
// Only hit the schedule API as a fallback when Queue-Times live data is unavailable.
if (!liveRides && apiId !== null) {
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,
}}>
<BackToCalendarLink />
<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 aside={liveRides ? (
<a
href="https://queue-times.com"
target="_blank"
rel="noopener noreferrer"
style={{
fontSize: "0.68rem",
color: "var(--color-text-dim)",
textDecoration: "none",
display: "flex",
alignItems: "center",
gap: 4,
transition: "color 120ms ease",
}}
className="park-name-link"
>
via queue-times.com
</a>
) : undefined}>
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}
isWeatherDelay={isWeatherDelay}
/>
) : (
<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, aside }: { children: React.ReactNode; aside?: React.ReactNode }) {
return (
<div style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
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,
display: "flex",
alignItems: "center",
}}>
{children}
</h2>
{aside}
</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>
);
}