feat: add per-ride history charts with wait time and uptime tracking
Build and Deploy / Build & Push (push) Successful in 3m7s
Build and Deploy / Build & Push (push) Successful in 3m7s
Adds a cron-driven sampler that snapshots Queue-Times waits and Six Flags Fast Lane data every 5 minutes into a new ride_wait_samples table, and a clickable per-ride detail page at /park/[id]/ride/[slug] with Today / 7d / 30d Recharts views plus a 30d uptime pill. Rides are keyed by Queue-Times' stable qt_ride_id so renames don't fragment history. Samples store pre-bucketed local_date and local_time in the park's IANA timezone so aggregations are pure SQL and DST-safe. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -138,6 +138,7 @@ export default async function ParkPage({ params, searchParams }: PageProps) {
|
||||
|
||||
{liveRides ? (
|
||||
<LiveRidePanel
|
||||
parkId={id}
|
||||
liveRides={liveRides}
|
||||
parkOpenToday={parkOpenToday}
|
||||
isWeatherDelay={isWeatherDelay}
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
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 <NoHistoryYet parkId={id} parkName={park.name} slug={slug} />;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
return <ErrorState parkId={id} parkName={park.name} />;
|
||||
}
|
||||
|
||||
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 (
|
||||
<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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user