0dc84c7597
The ride detail and park pages fetched with `next: { revalidate: 60 }`,
which is stale-while-revalidate. After hours of no traffic the Data Cache
held a morning snapshot; the first click served that stale value and only
the second request (e.g. a browser refresh) got the just-revalidated
payload. The endpoint also bundles live state with chart history, so one
stale fetch made the whole page wrong.
Switch the live-data fetches to `cache: "no-store"`. The calendar-month
fetch keeps its 5-min ISR since operating hours change slowly.
465 lines
15 KiB
TypeScript
465 lines
15 KiB
TypeScript
import type { Metadata } from "next";
|
|
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";
|
|
import { getBackendUrl } from "@/lib/api";
|
|
|
|
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 async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
|
const { id, slug } = await params;
|
|
const park = PARK_MAP.get(id);
|
|
if (!park) return { title: "Ride not found | Thoosie Calendar" };
|
|
const rideName = decodeURIComponent(slug).replace(/-/g, " ");
|
|
const title = `${rideName} — ${park.shortName} | Thoosie Calendar`;
|
|
const description = `Live wait time and uptime history for ${rideName} at ${park.name}.`;
|
|
return {
|
|
title,
|
|
description,
|
|
openGraph: { title, description },
|
|
};
|
|
}
|
|
|
|
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);
|
|
|
|
let res: Response;
|
|
try {
|
|
res = await fetch(`${getBackendUrl()}/api/parks/${id}/rides/${slug}`, {
|
|
cache: "no-store",
|
|
});
|
|
} catch {
|
|
return <ErrorState parkId={id} parkName={park.name} />;
|
|
}
|
|
|
|
if (res.status === 404) {
|
|
return <NoHistoryYet parkId={id} parkName={park.name} slug={slug} />;
|
|
}
|
|
|
|
if (!res.ok) {
|
|
return <ErrorState parkId={id} parkName={park.name} />;
|
|
}
|
|
|
|
let data: ApiResponse;
|
|
try {
|
|
data = (await res.json()) as ApiResponse;
|
|
} catch {
|
|
return <ErrorState parkId={id} parkName={park.name} />;
|
|
}
|
|
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 (
|
|
<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={`/park/${id}`} style={{
|
|
fontSize: "0.78rem",
|
|
color: "var(--color-text-secondary)",
|
|
textDecoration: "none",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 6,
|
|
}}>
|
|
← {park.shortName}
|
|
</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" }}>
|
|
{ride.name}
|
|
</span>
|
|
{ride.isCoaster && (
|
|
<span style={{ fontSize: "0.7rem", color: "var(--color-text-muted)" }}>🎢 Coaster</span>
|
|
)}
|
|
</header>
|
|
|
|
<main style={{ padding: "24px 32px", maxWidth: 1024, margin: "0 auto", display: "flex", flexDirection: "column", gap: 28 }}>
|
|
|
|
{/* ── Current state + uptime row ────────────────────────────────────── */}
|
|
<section style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
|
|
<CurrentStatePill live={live} hasFastLane={ride.hasFastLane} />
|
|
{last30d.length > 0 && (
|
|
<UptimePill uptime={last30dUptime} sampleCount={totalSamples30d} label="Uptime · last 30 days" />
|
|
)}
|
|
</section>
|
|
|
|
{/* ── Range tabs ────────────────────────────────────────────────────── */}
|
|
<section>
|
|
<RangeTabs parkId={id} slug={slug} active={tab} coverage={coverage} />
|
|
</section>
|
|
|
|
{/* ── Charts ────────────────────────────────────────────────────────── */}
|
|
<section>
|
|
{tab === "today" && (
|
|
<TodayPanel
|
|
samples={today}
|
|
hasFastLane={ride.hasFastLane}
|
|
sampleCount={coverage.todaySampleCount}
|
|
parkId={id}
|
|
firstSeen={ride.firstSeen}
|
|
/>
|
|
)}
|
|
{tab === "7d" && (
|
|
<RangePanel
|
|
data={last7d}
|
|
hasFastLane={ride.hasFastLane}
|
|
days={coverage.daysWith7d}
|
|
minDays={3}
|
|
windowLabel="7 days"
|
|
firstSeen={ride.firstSeen}
|
|
/>
|
|
)}
|
|
{tab === "30d" && (
|
|
<RangePanel
|
|
data={last30d}
|
|
hasFastLane={ride.hasFastLane}
|
|
days={coverage.daysWith30d}
|
|
minDays={10}
|
|
windowLabel="30 days"
|
|
firstSeen={ride.firstSeen}
|
|
/>
|
|
)}
|
|
</section>
|
|
|
|
{/* ── Attribution ───────────────────────────────────────────────────── */}
|
|
<footer style={{ marginTop: 12, fontSize: "0.68rem", color: "var(--color-text-dim)" }}>
|
|
Wait times via{" "}
|
|
<a href="https://queue-times.com" target="_blank" rel="noopener noreferrer" style={{ color: "var(--color-text-muted)" }}>
|
|
queue-times.com
|
|
</a>
|
|
{ride.hasFastLane && <> · Fast Lane via sixflags.com</>}
|
|
{" · "}Tracking since {formatDate(ride.firstSeen)}
|
|
</footer>
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Sub-components ─────────────────────────────────────────────────────────
|
|
|
|
function CurrentStatePill({
|
|
live,
|
|
hasFastLane,
|
|
}: {
|
|
live: ApiResponse["live"];
|
|
hasFastLane: boolean;
|
|
}) {
|
|
if (!live) {
|
|
return (
|
|
<div style={{
|
|
padding: "12px 16px",
|
|
background: "var(--color-surface)",
|
|
border: "1px solid var(--color-border)",
|
|
borderRadius: 8,
|
|
minWidth: 140,
|
|
}}>
|
|
<span style={{ fontSize: "0.65rem", textTransform: "uppercase", letterSpacing: "0.06em", color: "var(--color-text-muted)" }}>
|
|
Current
|
|
</span>
|
|
<div style={{ fontSize: "0.95rem", fontWeight: 600, color: "var(--color-text-muted)", marginTop: 4 }}>
|
|
Offline
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div style={{
|
|
padding: "12px 16px",
|
|
background: bg,
|
|
border: `1px solid ${border}`,
|
|
borderRadius: 8,
|
|
minWidth: 140,
|
|
}}>
|
|
<span style={{ fontSize: "0.65rem", textTransform: "uppercase", letterSpacing: "0.06em", color: "var(--color-text-muted)" }}>
|
|
Right now
|
|
</span>
|
|
<div style={{ fontSize: "1.5rem", fontWeight: 700, color: fg, lineHeight: 1, marginTop: 4 }}>
|
|
{!live.isOpen ? "Closed" : live.waitMinutes > 0 ? `${live.waitMinutes} min` : "Walk-on"}
|
|
</div>
|
|
{hasFastLane && live.isOpen && live.fastLaneMinutes !== null && (
|
|
<div style={{ fontSize: "0.72rem", color: "var(--color-accent)", fontWeight: 600, marginTop: 4 }}>
|
|
⚡ {live.fastLaneMinutes > 0 ? `${live.fastLaneMinutes} min` : "walk-on"}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div style={{ display: "flex", gap: 6, borderBottom: "1px solid var(--color-border)", paddingBottom: 0 }}>
|
|
{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 <span key={t.id} style={style} title="Not enough history yet">{t.label}</span>;
|
|
}
|
|
return (
|
|
<Link key={t.id} href={`/park/${parkId}/ride/${slug}?tab=${t.id}`} style={style}>
|
|
{t.label}
|
|
</Link>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TodayPanel({
|
|
samples,
|
|
hasFastLane,
|
|
sampleCount,
|
|
parkId,
|
|
firstSeen,
|
|
}: {
|
|
samples: TodaySample[];
|
|
hasFastLane: boolean;
|
|
sampleCount: number;
|
|
parkId: string;
|
|
firstSeen: string;
|
|
}) {
|
|
if (sampleCount < 12) {
|
|
return (
|
|
<EmptyState
|
|
title="Not enough data for today yet"
|
|
body={`We sample every 5 minutes while ${parkId} is open. The chart appears once we've collected about an hour of data. Tracking started ${formatDate(firstSeen)}.`}
|
|
/>
|
|
);
|
|
}
|
|
return (
|
|
<div>
|
|
<ChartHeading>Wait time today</ChartHeading>
|
|
<WaitTimeTodayChart samples={samples} hasFastLane={hasFastLane} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function RangePanel({
|
|
data,
|
|
hasFastLane,
|
|
days,
|
|
minDays,
|
|
windowLabel,
|
|
firstSeen,
|
|
}: {
|
|
data: DailyAggregate[];
|
|
hasFastLane: boolean;
|
|
days: number;
|
|
minDays: number;
|
|
windowLabel: string;
|
|
firstSeen: string;
|
|
}) {
|
|
if (days < minDays) {
|
|
return (
|
|
<EmptyState
|
|
title={`Not enough data for the ${windowLabel} view yet`}
|
|
body={`Need at least ${minDays} days of tracking; have ${days}. Tracking started ${formatDate(firstSeen)}.`}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 28 }}>
|
|
<div>
|
|
<ChartHeading>Regular wait — avg & max per day</ChartHeading>
|
|
<WeeklyStatsChart data={data} hasFastLane={hasFastLane} mode="regular" />
|
|
</div>
|
|
{hasFastLane && (
|
|
<div>
|
|
<ChartHeading>Fast Lane wait — avg & max per day</ChartHeading>
|
|
<WeeklyStatsChart data={data} hasFastLane={hasFastLane} mode="fastLane" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ChartHeading({ children }: { children: React.ReactNode }) {
|
|
return (
|
|
<h3 style={{
|
|
fontSize: "0.72rem",
|
|
fontWeight: 700,
|
|
color: "var(--color-text)",
|
|
letterSpacing: "0.06em",
|
|
textTransform: "uppercase",
|
|
margin: "0 0 12px",
|
|
}}>
|
|
{children}
|
|
</h3>
|
|
);
|
|
}
|
|
|
|
function EmptyState({ title, body }: { title: string; body: string }) {
|
|
return (
|
|
<div style={{
|
|
padding: "20px 24px",
|
|
background: "var(--color-surface)",
|
|
border: "1px dashed var(--color-border)",
|
|
borderRadius: 8,
|
|
color: "var(--color-text-muted)",
|
|
fontSize: "0.82rem",
|
|
lineHeight: 1.55,
|
|
}}>
|
|
<div style={{ fontWeight: 600, color: "var(--color-text-secondary)", marginBottom: 4 }}>
|
|
{title}
|
|
</div>
|
|
{body}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function NoHistoryYet({ parkId, parkName, slug }: { parkId: string; parkName: string; slug: string }) {
|
|
return (
|
|
<div style={{ minHeight: "100vh", background: "var(--color-bg)", padding: "48px 32px" }}>
|
|
<div style={{ maxWidth: 640, margin: "0 auto" }}>
|
|
<Link href={`/park/${parkId}`} style={{ fontSize: "0.78rem", color: "var(--color-text-secondary)", textDecoration: "none" }}>
|
|
← {parkName}
|
|
</Link>
|
|
<h1 style={{ fontSize: "1.4rem", fontWeight: 700, color: "var(--color-text)", marginTop: 24, marginBottom: 12 }}>
|
|
No history yet for {decodeURIComponent(slug).replace(/-/g, " ")}
|
|
</h1>
|
|
<p style={{ color: "var(--color-text-muted)", lineHeight: 1.6, fontSize: "0.9rem" }}>
|
|
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.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ErrorState({ parkId, parkName }: { parkId: string; parkName: string }) {
|
|
return (
|
|
<div style={{ minHeight: "100vh", background: "var(--color-bg)", padding: "48px 32px" }}>
|
|
<div style={{ maxWidth: 640, margin: "0 auto" }}>
|
|
<Link href={`/park/${parkId}`} style={{ fontSize: "0.78rem", color: "var(--color-text-secondary)", textDecoration: "none" }}>
|
|
← {parkName}
|
|
</Link>
|
|
<h1 style={{ fontSize: "1.4rem", fontWeight: 700, color: "var(--color-text)", marginTop: 24, marginBottom: 12 }}>
|
|
Could not load ride history
|
|
</h1>
|
|
<p style={{ color: "var(--color-text-muted)", lineHeight: 1.6, fontSize: "0.9rem" }}>
|
|
The backend is unreachable. Try again in a moment.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function formatDate(iso: string): string {
|
|
try {
|
|
return new Date(iso).toLocaleDateString("en-US", {
|
|
month: "short",
|
|
day: "numeric",
|
|
year: "numeric",
|
|
});
|
|
} catch {
|
|
return iso;
|
|
}
|
|
}
|