import Link from "next/link"; import { notFound } from "next/navigation"; import { PARK_MAP } from "@/lib/parks"; import UptimePill from "@/components/charts/UptimePill"; import WaitTimeTodayChart from "@/components/charts/WaitTimeTodayChart"; import WeeklyStatsChart from "@/components/charts/WeeklyStatsChart"; const BACKEND_URL = process.env.BACKEND_URL ?? "http://localhost:3001"; type Tab = "today" | "7d" | "30d"; interface TodaySample { localTime: string; isOpen: boolean; waitMinutes: number | null; fastLaneMinutes: number | null; } interface DailyAggregate { localDate: string; avgWait: number | null; maxWait: number | null; avgFastLane: number | null; maxFastLane: number | null; uptimePct: number; sampleCount: number; } interface ApiResponse { park: { id: string; name: string; shortName: string; timezone: string }; ride: { qtRideId: number; slug: string; name: string; isCoaster: boolean; hasFastLane: boolean; firstSeen: string; lastSeen: string; }; live: { isOpen: boolean; waitMinutes: number; hasFastLane: boolean; fastLaneMinutes: number | null; lastUpdated: string; } | null; todayLocal: string; today: TodaySample[]; last7d: DailyAggregate[]; last30d: DailyAggregate[]; coverage: { daysWith7d: number; daysWith30d: number; todaySampleCount: number }; } interface PageProps { params: Promise<{ id: string; slug: string }>; searchParams: Promise<{ tab?: string }>; } function parseTab(raw: string | undefined): Tab { if (raw === "7d" || raw === "30d") return raw; return "today"; } export default async function RideDetailPage({ params, searchParams }: PageProps) { const { id, slug } = await params; const { tab: tabParam } = await searchParams; const park = PARK_MAP.get(id); if (!park) notFound(); const tab = parseTab(tabParam); const res = await fetch(`${BACKEND_URL}/api/parks/${id}/rides/${slug}`, { next: { revalidate: 60 }, }); if (res.status === 404) { return ; } if (!res.ok) { return ; } const data: ApiResponse = await res.json(); const { ride, live, today, last7d, last30d, coverage } = data; const last30dUptime = last30d.length ? last30d.reduce((s, d) => s + d.uptimePct * d.sampleCount, 0) / Math.max(1, last30d.reduce((s, d) => s + d.sampleCount, 0)) : 0; const totalSamples30d = last30d.reduce((s, d) => s + d.sampleCount, 0); return (
{/* ── Header ─────────────────────────────────────────────────────────── */}
← {park.shortName}
{ride.name} {ride.isCoaster && ( 🎢 Coaster )}
{/* ── Current state + uptime row ────────────────────────────────────── */}
{last30d.length > 0 && ( )}
{/* ── Range tabs ────────────────────────────────────────────────────── */}
{/* ── Charts ────────────────────────────────────────────────────────── */}
{tab === "today" && ( )} {tab === "7d" && ( )} {tab === "30d" && ( )}
{/* ── Attribution ───────────────────────────────────────────────────── */}
Wait times via{" "} queue-times.com {ride.hasFastLane && <> · Fast Lane via sixflags.com} {" · "}Tracking since {formatDate(ride.firstSeen)}
); } // ── Sub-components ───────────────────────────────────────────────────────── function CurrentStatePill({ live, hasFastLane, }: { live: ApiResponse["live"]; hasFastLane: boolean; }) { if (!live) { return (
Current
Offline
); } const fg = live.isOpen ? "var(--color-open-text)" : "var(--color-text-muted)"; const bg = live.isOpen ? "var(--color-open-bg)" : "var(--color-surface)"; const border = live.isOpen ? "var(--color-open-border)" : "var(--color-border)"; return (
Right now
{!live.isOpen ? "Closed" : live.waitMinutes > 0 ? `${live.waitMinutes} min` : "Walk-on"}
{hasFastLane && live.isOpen && live.fastLaneMinutes !== null && (
⚡ {live.fastLaneMinutes > 0 ? `${live.fastLaneMinutes} min` : "walk-on"}
)}
); } function RangeTabs({ parkId, slug, active, coverage, }: { parkId: string; slug: string; active: Tab; coverage: ApiResponse["coverage"]; }) { const tabs: { id: Tab; label: string; enabled: boolean }[] = [ { id: "today", label: "Today", enabled: true }, { id: "7d", label: "7 days", enabled: coverage.daysWith7d >= 1 }, { id: "30d", label: "30 days", enabled: coverage.daysWith30d >= 1 }, ]; return (
{tabs.map((t) => { const isActive = t.id === active; const style: React.CSSProperties = { padding: "8px 16px", fontSize: "0.78rem", fontWeight: 600, color: isActive ? "var(--color-text)" : t.enabled ? "var(--color-text-muted)" : "var(--color-text-dim)", background: "transparent", border: "none", borderBottom: isActive ? "2px solid var(--color-accent)" : "2px solid transparent", marginBottom: -1, textDecoration: "none", cursor: t.enabled ? "pointer" : "not-allowed", }; if (!t.enabled) { return {t.label}; } return ( {t.label} ); })}
); } function TodayPanel({ samples, hasFastLane, sampleCount, parkId, firstSeen, }: { samples: TodaySample[]; hasFastLane: boolean; sampleCount: number; parkId: string; firstSeen: string; }) { if (sampleCount < 12) { return ( ); } return (
Wait time today
); } function RangePanel({ data, hasFastLane, days, minDays, windowLabel, firstSeen, }: { data: DailyAggregate[]; hasFastLane: boolean; days: number; minDays: number; windowLabel: string; firstSeen: string; }) { if (days < minDays) { return ( ); } return (
Regular wait — avg & max per day
{hasFastLane && (
Fast Lane wait — avg & max per day
)}
); } function ChartHeading({ children }: { children: React.ReactNode }) { return (

{children}

); } function EmptyState({ title, body }: { title: string; body: string }) { return (
{title}
{body}
); } function NoHistoryYet({ parkId, parkName, slug }: { parkId: string; parkName: string; slug: string }) { return (
← {parkName}

No history yet for {decodeURIComponent(slug).replace(/-/g, " ")}

We start tracking a ride the first time we see it open in the live feed. Check back after the park is open — once we've recorded an hour of samples, charts will appear here.

); } function ErrorState({ parkId, parkName }: { parkId: string; parkName: string }) { return (
← {parkName}

Could not load ride history

The backend is unreachable. Try again in a moment.

); } function formatDate(iso: string): string { try { return new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", }); } catch { return iso; } }