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 {
recordedAt: string;
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 (
);
}
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 (
);
}
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;
}
}