feat: add per-ride history charts with wait time and uptime tracking
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:
2026-05-29 23:35:27 -04:00
parent bfe099322f
commit 4f838d99c1
25 changed files with 2052 additions and 18 deletions
+9
View File
@@ -110,6 +110,15 @@
background: var(--color-surface-hover) !important; background: var(--color-surface-hover) !important;
} }
/* ── Ride row link hover (LiveRidePanel) ────────────────────────────────── */
.ride-row-link {
transition: background 120ms ease, border-color 120ms ease;
}
.ride-row-link:hover {
background: var(--color-surface-hover) !important;
border-color: var(--color-accent) !important;
}
/* ── Park month calendar — responsive row heights ───────────────────────── */ /* ── Park month calendar — responsive row heights ───────────────────────── */
/* Mobile: fixed uniform rows so narrow columns don't cause height variance */ /* Mobile: fixed uniform rows so narrow columns don't cause height variance */
.park-calendar-grid { .park-calendar-grid {
+1
View File
@@ -138,6 +138,7 @@ export default async function ParkPage({ params, searchParams }: PageProps) {
{liveRides ? ( {liveRides ? (
<LiveRidePanel <LiveRidePanel
parkId={id}
liveRides={liveRides} liveRides={liveRides}
parkOpenToday={parkOpenToday} parkOpenToday={parkOpenToday}
isWeatherDelay={isWeatherDelay} isWeatherDelay={isWeatherDelay}
+439
View File
@@ -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 &amp; max per day</ChartHeading>
<WeeklyStatsChart data={data} hasFastLane={hasFastLane} mode="regular" />
</div>
{hasFastLane && (
<div>
<ChartHeading>Fast Lane wait avg &amp; 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&apos;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;
}
}
+2 -1
View File
@@ -6,7 +6,8 @@
"dev": "tsx watch src/index.ts", "dev": "tsx watch src/index.ts",
"build": "tsc", "build": "tsc",
"start": "node dist/index.js", "start": "node dist/index.js",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit",
"test": "tsx --test tests/*.test.ts"
}, },
"dependencies": { "dependencies": {
"@hono/node-server": "^2.0.0", "@hono/node-server": "^2.0.0",
+35
View File
@@ -28,6 +28,41 @@ export function getDb(): Database.Database {
} catch { } catch {
// Column already exists // Column already exists
} }
// Per-ride canonical record. PK is (park_id, qt_ride_id) so renames
// don't fragment history — the slug just provides pretty URLs.
_db.exec(`
CREATE TABLE IF NOT EXISTS rides (
park_id TEXT NOT NULL,
qt_ride_id INTEGER NOT NULL,
slug TEXT NOT NULL,
name TEXT NOT NULL,
is_coaster INTEGER NOT NULL DEFAULT 0,
has_fast_lane INTEGER NOT NULL DEFAULT 0,
first_seen TEXT NOT NULL,
last_seen TEXT NOT NULL,
PRIMARY KEY (park_id, qt_ride_id)
)
`);
_db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_rides_slug ON rides (park_id, slug)`);
// Time-series wait time samples. recorded_at is UTC; local_date/local_time
// are pre-bucketed in the park's IANA timezone at insert time so reads are
// pure SQL and DST-safe.
_db.exec(`
CREATE TABLE IF NOT EXISTS ride_wait_samples (
park_id TEXT NOT NULL,
qt_ride_id INTEGER NOT NULL,
recorded_at TEXT NOT NULL,
local_date TEXT NOT NULL,
local_time TEXT NOT NULL,
is_open INTEGER NOT NULL,
wait_minutes INTEGER,
fast_lane_minutes INTEGER,
PRIMARY KEY (park_id, qt_ride_id, recorded_at)
)
`);
return _db; return _db;
} }
+219
View File
@@ -161,3 +161,222 @@ export function getParkDayCount(): number {
export function transact(fn: () => void): void { export function transact(fn: () => void): void {
getDb().transaction(fn)(); getDb().transaction(fn)();
} }
// ─── Ride history ────────────────────────────────────────────────────────────
export interface RideRow {
parkId: string;
qtRideId: number;
slug: string;
name: string;
isCoaster: boolean;
hasFastLane: boolean;
firstSeen: string;
lastSeen: string;
}
interface RideDbRow {
park_id: string;
qt_ride_id: number;
slug: string;
name: string;
is_coaster: number;
has_fast_lane: number;
first_seen: string;
last_seen: string;
}
function rowToRide(row: RideDbRow): RideRow {
return {
parkId: row.park_id,
qtRideId: row.qt_ride_id,
slug: row.slug,
name: row.name,
isCoaster: row.is_coaster === 1,
hasFastLane: row.has_fast_lane === 1,
firstSeen: row.first_seen,
lastSeen: row.last_seen,
};
}
/**
* Insert a ride if new, otherwise update its mutable fields (name, slug,
* has_fast_lane, last_seen). is_coaster is sticky once set true.
*/
export function upsertRide(
parkId: string,
qtRideId: number,
slug: string,
name: string,
isCoaster: boolean,
hasFastLane: boolean,
now: string,
): void {
getDb()
.prepare(
`INSERT INTO rides (park_id, qt_ride_id, slug, name, is_coaster, has_fast_lane, first_seen, last_seen)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (park_id, qt_ride_id) DO UPDATE SET
slug = excluded.slug,
name = excluded.name,
is_coaster = MAX(rides.is_coaster, excluded.is_coaster),
has_fast_lane = MAX(rides.has_fast_lane, excluded.has_fast_lane),
last_seen = excluded.last_seen`,
)
.run(parkId, qtRideId, slug, name, isCoaster ? 1 : 0, hasFastLane ? 1 : 0, now, now);
}
export function insertSample(
parkId: string,
qtRideId: number,
recordedAt: string,
localDate: string,
localTime: string,
isOpen: boolean,
waitMinutes: number | null,
fastLaneMinutes: number | null,
): void {
getDb()
.prepare(
`INSERT OR IGNORE INTO ride_wait_samples
(park_id, qt_ride_id, recorded_at, local_date, local_time, is_open, wait_minutes, fast_lane_minutes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(
parkId,
qtRideId,
recordedAt,
localDate,
localTime,
isOpen ? 1 : 0,
waitMinutes,
fastLaneMinutes,
);
}
export function getRideBySlug(parkId: string, slug: string): RideRow | null {
const row = getDb()
.prepare(
`SELECT park_id, qt_ride_id, slug, name, is_coaster, has_fast_lane, first_seen, last_seen
FROM rides
WHERE park_id = ? AND slug = ?`,
)
.get(parkId, slug) as RideDbRow | undefined;
return row ? rowToRide(row) : null;
}
export function listRidesForPark(parkId: string): RideRow[] {
const rows = getDb()
.prepare(
`SELECT park_id, qt_ride_id, slug, name, is_coaster, has_fast_lane, first_seen, last_seen
FROM rides
WHERE park_id = ?
ORDER BY name`,
)
.all(parkId) as RideDbRow[];
return rows.map(rowToRide);
}
export interface DailySample {
localTime: string;
isOpen: boolean;
waitMinutes: number | null;
fastLaneMinutes: number | null;
}
export function getRideSamplesForDay(
parkId: string,
qtRideId: number,
localDate: string,
): DailySample[] {
const rows = getDb()
.prepare(
`SELECT local_time, is_open, wait_minutes, fast_lane_minutes
FROM ride_wait_samples
WHERE park_id = ? AND qt_ride_id = ? AND local_date = ?
ORDER BY local_time`,
)
.all(parkId, qtRideId, localDate) as {
local_time: string;
is_open: number;
wait_minutes: number | null;
fast_lane_minutes: number | null;
}[];
return rows.map((r) => ({
localTime: r.local_time,
isOpen: r.is_open === 1,
waitMinutes: r.wait_minutes,
fastLaneMinutes: r.fast_lane_minutes,
}));
}
export interface DailyAggregate {
localDate: string;
avgWait: number | null;
maxWait: number | null;
avgFastLane: number | null;
maxFastLane: number | null;
uptimePct: number;
sampleCount: number;
}
export function getRideDailyAggregates(
parkId: string,
qtRideId: number,
sinceLocalDate: string,
): DailyAggregate[] {
const rows = getDb()
.prepare(
`SELECT local_date,
AVG(CASE WHEN is_open = 1 THEN wait_minutes END) AS avg_wait,
MAX(CASE WHEN is_open = 1 THEN wait_minutes END) AS max_wait,
AVG(CASE WHEN is_open = 1 THEN fast_lane_minutes END) AS avg_fl,
MAX(CASE WHEN is_open = 1 THEN fast_lane_minutes END) AS max_fl,
CAST(SUM(is_open) AS REAL) / COUNT(*) AS uptime_pct,
COUNT(*) AS sample_count
FROM ride_wait_samples
WHERE park_id = ? AND qt_ride_id = ? AND local_date >= ?
GROUP BY local_date
ORDER BY local_date`,
)
.all(parkId, qtRideId, sinceLocalDate) as {
local_date: string;
avg_wait: number | null;
max_wait: number | null;
avg_fl: number | null;
max_fl: number | null;
uptime_pct: number;
sample_count: number;
}[];
return rows.map((r) => ({
localDate: r.local_date,
avgWait: r.avg_wait,
maxWait: r.max_wait,
avgFastLane: r.avg_fl,
maxFastLane: r.max_fl,
uptimePct: r.uptime_pct,
sampleCount: r.sample_count,
}));
}
/**
* Number of distinct local_date values for a ride in the given window.
* Used to decide whether 7d/30d charts have enough data to render.
*/
export function countRideDays(parkId: string, qtRideId: number, sinceLocalDate: string): number {
const row = getDb()
.prepare(
`SELECT COUNT(DISTINCT local_date) AS days
FROM ride_wait_samples
WHERE park_id = ? AND qt_ride_id = ? AND local_date >= ?`,
)
.get(parkId, qtRideId, sinceLocalDate) as { days: number };
return row.days;
}
export function getRideSampleCount(): number {
const row = getDb()
.prepare(`SELECT COUNT(*) AS count FROM ride_wait_samples`)
.get() as { count: number };
return row.count;
}
+2
View File
@@ -9,6 +9,7 @@ import { startScheduler } from "./services/scheduler";
import calendarRoutes from "./routes/calendar"; import calendarRoutes from "./routes/calendar";
import parksRoutes from "./routes/parks"; import parksRoutes from "./routes/parks";
import ridesRoutes from "./routes/rides"; import ridesRoutes from "./routes/rides";
import rideHistoryRoutes from "./routes/ride-history";
import statusRoutes from "./routes/status"; import statusRoutes from "./routes/status";
import scrapeRoutes from "./routes/scrape"; import scrapeRoutes from "./routes/scrape";
@@ -22,6 +23,7 @@ app.use("*", cors());
app.route("/api/calendar", calendarRoutes); app.route("/api/calendar", calendarRoutes);
app.route("/api/parks", parksRoutes); app.route("/api/parks", parksRoutes);
app.route("/api/parks", ridesRoutes); app.route("/api/parks", ridesRoutes);
app.route("/api/parks", rideHistoryRoutes);
app.route("/api/status", statusRoutes); app.route("/api/status", statusRoutes);
app.route("/api/scrape", scrapeRoutes); app.route("/api/scrape", scrapeRoutes);
+113
View File
@@ -0,0 +1,113 @@
/**
* Ride detail + history endpoint.
*
* GET /api/parks/:parkId/rides/:slug
* Returns ride metadata, today's per-sample series, and 7d + 30d
* per-day aggregates in a single round-trip.
*
* The frontend renders Today / 7d / 30d tabs from one payload — no
* client-side fetching of additional ranges. Cache: 60s public.
*/
import { Hono } from "hono";
import { PARK_MAP } from "../../../lib/parks";
import {
getRideBySlug,
getRideSamplesForDay,
getRideDailyAggregates,
countRideDays,
type DailySample,
type DailyAggregate,
} from "../db/queries";
import { liveRidesCache, fastLaneCache } from "../services/live-cache";
import { slugifyRideName } from "../../../lib/ride-slug";
import { lookupFastLane } from "../../../lib/scrapers/sixflags-waittimes";
const app = new Hono();
function formatLocalDate(d: Date, tz: string): string {
return new Intl.DateTimeFormat("en-CA", {
timeZone: tz,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(d);
}
/** YYYY-MM-DD `n` days before the given local date (calendar-day math). */
function daysAgoIso(localDate: string, n: number): string {
const [y, m, d] = localDate.split("-").map(Number);
const date = new Date(Date.UTC(y, m - 1, d));
date.setUTCDate(date.getUTCDate() - n);
return date.toISOString().slice(0, 10);
}
app.get("/:parkId/rides/:slug", (c) => {
const parkId = c.req.param("parkId");
const slug = c.req.param("slug");
const park = PARK_MAP.get(parkId);
if (!park) return c.json({ error: "Park not found" }, 404);
const ride = getRideBySlug(parkId, slug);
if (!ride) {
return c.json({ error: "Ride not found or no history yet" }, 404);
}
const now = new Date();
const todayLocal = formatLocalDate(now, park.timezone);
const since7d = daysAgoIso(todayLocal, 6); // last 7 calendar days inclusive
const since30d = daysAgoIso(todayLocal, 29); // last 30 calendar days inclusive
const today: DailySample[] = getRideSamplesForDay(parkId, ride.qtRideId, todayLocal);
const last7d: DailyAggregate[] = getRideDailyAggregates(parkId, ride.qtRideId, since7d);
const last30d: DailyAggregate[] = getRideDailyAggregates(parkId, ride.qtRideId, since30d);
const daysWith7d = countRideDays(parkId, ride.qtRideId, since7d);
const daysWith30d = countRideDays(parkId, ride.qtRideId, since30d);
// Best-effort current live state from the shared cache (no upstream fetch
// — the cache is warmed by Tier-5 every 5 min and by the /rides route).
const liveRides = liveRidesCache.get(parkId);
const liveMatch = liveRides?.rides.find((r) => slugifyRideName(r.name) === slug) ?? null;
const fastLaneCacheEntry = fastLaneCache.get(parkId);
const flMatch = liveMatch && fastLaneCacheEntry ? lookupFastLane(liveMatch.name, fastLaneCacheEntry) : null;
c.header("Cache-Control", "public, max-age=60, stale-while-revalidate=120");
return c.json({
park: {
id: park.id,
name: park.name,
shortName: park.shortName,
timezone: park.timezone,
},
ride: {
qtRideId: ride.qtRideId,
slug: ride.slug,
name: ride.name,
isCoaster: ride.isCoaster,
hasFastLane: ride.hasFastLane,
firstSeen: ride.firstSeen,
lastSeen: ride.lastSeen,
},
live: liveMatch
? {
isOpen: liveMatch.isOpen,
waitMinutes: liveMatch.waitMinutes,
hasFastLane: Boolean(flMatch?.hasFastLane),
fastLaneMinutes: liveMatch.isOpen ? (flMatch?.fastLaneMinutes ?? null) : null,
lastUpdated: liveMatch.lastUpdated,
}
: null,
todayLocal,
today,
last7d,
last30d,
coverage: {
daysWith7d,
daysWith30d,
todaySampleCount: today.length,
},
});
});
export default app;
+11 -5
View File
@@ -7,12 +7,9 @@ import { fetchLiveRides } from "../../../lib/scrapers/queuetimes";
import { scrapeRidesForDay } from "../../../lib/scrapers/sixflags"; import { scrapeRidesForDay } from "../../../lib/scrapers/sixflags";
import { fetchFastLaneWaits, lookupFastLane } from "../../../lib/scrapers/sixflags-waittimes"; import { fetchFastLaneWaits, lookupFastLane } from "../../../lib/scrapers/sixflags-waittimes";
import { getDayData } from "../db/queries"; import { getDayData } from "../db/queries";
import { TtlCache } from "../services/cache"; import { liveRidesCache, fastLaneCache } from "../services/live-cache";
import { slugifyRideName } from "../../../lib/ride-slug";
import type { LiveRidesResult } from "../../../lib/scrapers/queuetimes"; import type { LiveRidesResult } from "../../../lib/scrapers/queuetimes";
import type { FastLaneResult } from "../../../lib/scrapers/sixflags-waittimes";
const liveRidesCache = new TtlCache<LiveRidesResult | null>(5 * 60 * 1000);
const fastLaneCache = new TtlCache<FastLaneResult | null>(5 * 60 * 1000);
const app = new Hono(); const app = new Hono();
@@ -68,6 +65,15 @@ app.get("/:id/rides", async (c) => {
}; };
} }
} }
// Attach URL slug to each live ride so the frontend can build links
// without re-slugifying. Same algorithm the sampler uses for the rides table.
if (liveRides) {
liveRides = {
...liveRides,
rides: liveRides.rides.map((r) => ({ ...r, slug: slugifyRideName(r.name) })),
};
}
} }
const isWeatherDelay = const isWeatherDelay =
+5 -1
View File
@@ -1,5 +1,6 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { scrapeToday, scrapeCurrentMonth, scrapeUpcomingMonths, scrapeFullYear } from "../services/scraper"; import { scrapeToday, scrapeCurrentMonth, scrapeUpcomingMonths, scrapeFullYear } from "../services/scraper";
import { sampleAllOpenParks } from "../services/wait-sampler";
const app = new Hono(); const app = new Hono();
@@ -23,8 +24,11 @@ app.post("/trigger", async (c) => {
case "force": case "force":
result = await scrapeFullYear(true); result = await scrapeFullYear(true);
break; break;
case "samples":
result = await sampleAllOpenParks();
break;
default: default:
return c.json({ error: "Invalid scope. Use: today, month, upcoming, full, force" }, 400); return c.json({ error: "Invalid scope. Use: today, month, upcoming, full, force, samples" }, 400);
} }
return c.json(result); return c.json(result);
+17
View File
@@ -0,0 +1,17 @@
/**
* Shared in-memory caches for live ride data.
*
* Both the on-demand `/api/parks/:id/rides` route and the Tier-5 wait
* sampler hit the same upstream APIs (Queue-Times + Six Flags wait-times).
* Sharing the cache means the sampler "warms" it every 5 minutes, so
* subsequent user requests hit a fresh cache without re-fetching.
*/
import { TtlCache } from "./cache";
import type { LiveRidesResult } from "../../../lib/scrapers/queuetimes";
import type { FastLaneResult } from "../../../lib/scrapers/sixflags-waittimes";
const FIVE_MIN = 5 * 60 * 1000;
export const liveRidesCache = new TtlCache<LiveRidesResult | null>(FIVE_MIN);
export const fastLaneCache = new TtlCache<FastLaneResult | null>(FIVE_MIN);
+15
View File
@@ -1,5 +1,6 @@
import cron from "node-cron"; import cron from "node-cron";
import { scrapeToday, scrapeCurrentMonth, scrapeUpcomingMonths, scrapeFullYear } from "./scraper"; import { scrapeToday, scrapeCurrentMonth, scrapeUpcomingMonths, scrapeFullYear } from "./scraper";
import { sampleAllOpenParks } from "./wait-sampler";
import { getParkDayCount } from "../db/queries"; import { getParkDayCount } from "../db/queries";
let initialized = false; let initialized = false;
@@ -32,11 +33,25 @@ export function startScheduler(): void {
await scrapeFullYear().catch((err) => console.error("[scheduler] tier-4 error:", err)); await scrapeFullYear().catch((err) => console.error("[scheduler] tier-4 error:", err));
}); });
// Tier 5: Wait-time samples — every 5 minutes for parks open today
cron.schedule("*/5 * * * *", async () => {
try {
const r = await sampleAllOpenParks();
console.log(
`[scheduler] tier-5: sampled ${r.parksSampled} parks, ${r.samplesWritten} samples, ` +
`${r.weatherDelayed} weather-delayed, ${r.errors} errors`,
);
} catch (err) {
console.error("[scheduler] tier-5 error:", err);
}
});
console.log("[scheduler] cron jobs registered"); console.log("[scheduler] cron jobs registered");
console.log(" tier-1: today — hourly (Mar-Dec)"); console.log(" tier-1: today — hourly (Mar-Dec)");
console.log(" tier-2: current month — every 6h"); console.log(" tier-2: current month — every 6h");
console.log(" tier-3: upcoming — 3 AM + 3 PM"); console.log(" tier-3: upcoming — 3 AM + 3 PM");
console.log(" tier-4: full year — 3 AM daily"); console.log(" tier-4: full year — 3 AM daily");
console.log(" tier-5: wait samples — every 5 min");
const existingRows = getParkDayCount(); const existingRows = getParkDayCount();
if (existingRows < 50) { if (existingRows < 50) {
+157
View File
@@ -0,0 +1,157 @@
/**
* Tier-5 wait-time sampler. Runs every 5 minutes via cron.
*
* For each park whose `park_days` row marks it open today:
* 1. Fetch live rides (Queue-Times) and Fast Lane waits (Six Flags) —
* reusing the shared TtlCache so we don't double-hit upstreams.
* 2. Detect the "weather delay" case (rides exist but all closed); skip
* writes for that park so it doesn't pollute uptime stats.
* 3. Upsert each ride into `rides` and INSERT OR IGNORE a sample row.
*
* Park-local date/time are computed at insert time via Intl.DateTimeFormat
* with the park's IANA timezone — DST-safe, automatic.
*
* Parks are fanned out in chunks of 6 to bound concurrency.
*/
import { PARKS } from "../../../lib/parks";
import type { Park } from "../../../lib/scrapers/types";
import { QUEUE_TIMES_IDS } from "../../../lib/queue-times-map";
import { getCoasterSet } from "../../../lib/coaster-data";
import { getTodayLocal } from "../../../lib/env";
import { fetchLiveRides } from "../../../lib/scrapers/queuetimes";
import { fetchFastLaneWaits, lookupFastLane } from "../../../lib/scrapers/sixflags-waittimes";
import { slugifyRideName } from "../../../lib/ride-slug";
import { formatLocalDate, formatLocalTime } from "../../../lib/timezone";
import { liveRidesCache, fastLaneCache } from "./live-cache";
import { getDayData, upsertRide, insertSample, transact } from "../db/queries";
const PARALLEL_CHUNK = 6;
export interface SampleRunResult {
parksSampled: number;
parksSkipped: number;
ridesUpserted: number;
samplesWritten: number;
weatherDelayed: number;
errors: number;
}
async function samplePark(park: Park, now: Date): Promise<{
ridesUpserted: number;
samplesWritten: number;
weatherDelayed: boolean;
error: boolean;
}> {
const queueTimesId = QUEUE_TIMES_IDS[park.id];
if (!queueTimesId) {
return { ridesUpserted: 0, samplesWritten: 0, weatherDelayed: false, error: false };
}
try {
// Live rides — reuse cache; fetch on miss.
let liveRides = liveRidesCache.get(park.id);
if (liveRides === null) {
const coasterSet = getCoasterSet(park.id);
liveRides = await fetchLiveRides(queueTimesId, coasterSet).catch(() => null);
if (liveRides) liveRidesCache.set(park.id, liveRides);
}
if (!liveRides || liveRides.rides.length === 0) {
return { ridesUpserted: 0, samplesWritten: 0, weatherDelayed: false, error: false };
}
// Weather-delay heuristic — skip writing so uptime stays honest.
const anyOpen = liveRides.rides.some((r) => r.isOpen);
if (!anyOpen) {
return { ridesUpserted: 0, samplesWritten: 0, weatherDelayed: true, error: false };
}
// Fast Lane — reuse cache; fetch on miss.
let fastLane = fastLaneCache.get(park.id);
if (fastLane === null) {
fastLane = await fetchFastLaneWaits(park.apiId).catch(() => null);
if (fastLane) fastLaneCache.set(park.id, fastLane);
}
const recordedAt = now.toISOString();
const localDate = formatLocalDate(now, park.timezone);
const localTime = formatLocalTime(now, park.timezone);
let ridesUpserted = 0;
let samplesWritten = 0;
transact(() => {
for (const ride of liveRides!.rides) {
if (!ride.qtRideId) continue;
const slug = slugifyRideName(ride.name);
const flMatch = fastLane ? lookupFastLane(ride.name, fastLane) : null;
const hasFastLane = Boolean(flMatch?.hasFastLane);
const fastLaneMinutes =
ride.isOpen && flMatch ? flMatch.fastLaneMinutes : null;
upsertRide(
park.id,
ride.qtRideId,
slug,
ride.name,
ride.isCoaster,
hasFastLane,
recordedAt,
);
ridesUpserted++;
insertSample(
park.id,
ride.qtRideId,
recordedAt,
localDate,
localTime,
ride.isOpen,
ride.isOpen ? ride.waitMinutes : null,
fastLaneMinutes,
);
samplesWritten++;
}
});
return { ridesUpserted, samplesWritten, weatherDelayed: false, error: false };
} catch (err) {
console.error(`[wait-sampler] error sampling ${park.id}:`, err);
return { ridesUpserted: 0, samplesWritten: 0, weatherDelayed: false, error: true };
}
}
export async function sampleAllOpenParks(): Promise<SampleRunResult> {
const today = getTodayLocal();
const now = new Date();
const result: SampleRunResult = {
parksSampled: 0,
parksSkipped: 0,
ridesUpserted: 0,
samplesWritten: 0,
weatherDelayed: 0,
errors: 0,
};
// Filter to parks open today.
const openParks = PARKS.filter((park) => {
const day = getDayData(park.id, today);
return day?.isOpen ?? false;
});
result.parksSkipped = PARKS.length - openParks.length;
// Fan out in bounded chunks so we don't blast 24 requests in parallel.
for (let i = 0; i < openParks.length; i += PARALLEL_CHUNK) {
const chunk = openParks.slice(i, i + PARALLEL_CHUNK);
const chunkResults = await Promise.all(chunk.map((park) => samplePark(park, now)));
for (const r of chunkResults) {
if (r.error) result.errors++;
else if (r.weatherDelayed) result.weatherDelayed++;
else if (r.samplesWritten > 0) result.parksSampled++;
result.ridesUpserted += r.ridesUpserted;
result.samplesWritten += r.samplesWritten;
}
}
return result;
}
+193
View File
@@ -0,0 +1,193 @@
/**
* Aggregation query tests.
*
* Spins up an in-memory better-sqlite3 instance with the production schema,
* seeds known samples, and verifies the daily aggregation produces the right
* avg / max / uptime / sample_count. Locks the SQL semantics so a refactor
* can't silently change the meaning of "uptime" or how closed samples are
* filtered.
*
* Run with: npm --prefix backend test
*/
import { test } from "node:test";
import assert from "node:assert/strict";
import Database from "better-sqlite3";
const SCHEMA = `
CREATE TABLE ride_wait_samples (
park_id TEXT NOT NULL,
qt_ride_id INTEGER NOT NULL,
recorded_at TEXT NOT NULL,
local_date TEXT NOT NULL,
local_time TEXT NOT NULL,
is_open INTEGER NOT NULL,
wait_minutes INTEGER,
fast_lane_minutes INTEGER,
PRIMARY KEY (park_id, qt_ride_id, recorded_at)
);
`;
const AGGREGATE_QUERY = `
SELECT local_date,
AVG(CASE WHEN is_open = 1 THEN wait_minutes END) AS avg_wait,
MAX(CASE WHEN is_open = 1 THEN wait_minutes END) AS max_wait,
AVG(CASE WHEN is_open = 1 THEN fast_lane_minutes END) AS avg_fl,
MAX(CASE WHEN is_open = 1 THEN fast_lane_minutes END) AS max_fl,
CAST(SUM(is_open) AS REAL) / COUNT(*) AS uptime_pct,
COUNT(*) AS sample_count
FROM ride_wait_samples
WHERE park_id = ? AND qt_ride_id = ? AND local_date >= ?
GROUP BY local_date
ORDER BY local_date
`;
interface Sample {
parkId: string;
qtRideId: number;
recordedAt: string;
localDate: string;
localTime: string;
isOpen: boolean;
waitMinutes: number | null;
fastLaneMinutes: number | null;
}
function setup(samples: Sample[]) {
const db = new Database(":memory:");
db.exec(SCHEMA);
const stmt = db.prepare(
`INSERT INTO ride_wait_samples
(park_id, qt_ride_id, recorded_at, local_date, local_time, is_open, wait_minutes, fast_lane_minutes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
);
for (const s of samples) {
stmt.run(
s.parkId,
s.qtRideId,
s.recordedAt,
s.localDate,
s.localTime,
s.isOpen ? 1 : 0,
s.waitMinutes,
s.fastLaneMinutes,
);
}
return db;
}
interface AggregateRow {
local_date: string;
avg_wait: number | null;
max_wait: number | null;
avg_fl: number | null;
max_fl: number | null;
uptime_pct: number;
sample_count: number;
}
test("avg and max are computed only over open samples", () => {
const db = setup([
s("p", 1, "2026-05-29", "10:00", true, 10, null),
s("p", 1, "2026-05-29", "10:05", true, 20, null),
s("p", 1, "2026-05-29", "10:10", true, 30, null),
s("p", 1, "2026-05-29", "10:15", false, null, null),
s("p", 1, "2026-05-29", "10:20", true, 40, null),
]);
const rows = db.prepare(AGGREGATE_QUERY).all("p", 1, "2026-05-29") as AggregateRow[];
assert.equal(rows.length, 1);
assert.equal(rows[0].max_wait, 40);
assert.equal(rows[0].avg_wait, (10 + 20 + 30 + 40) / 4);
assert.equal(rows[0].sample_count, 5);
});
test("uptime_pct is open_samples / total_samples", () => {
const db = setup([
s("p", 1, "2026-05-29", "10:00", true, 10, null),
s("p", 1, "2026-05-29", "10:05", true, 20, null),
s("p", 1, "2026-05-29", "10:10", false, null, null),
s("p", 1, "2026-05-29", "10:15", false, null, null),
]);
const rows = db.prepare(AGGREGATE_QUERY).all("p", 1, "2026-05-29") as AggregateRow[];
assert.equal(rows[0].uptime_pct, 0.5);
});
test("an all-closed day reports uptime 0 and null waits", () => {
const db = setup([
s("p", 1, "2026-05-29", "10:00", false, null, null),
s("p", 1, "2026-05-29", "10:05", false, null, null),
]);
const rows = db.prepare(AGGREGATE_QUERY).all("p", 1, "2026-05-29") as AggregateRow[];
assert.equal(rows.length, 1);
assert.equal(rows[0].uptime_pct, 0);
assert.equal(rows[0].avg_wait, null);
assert.equal(rows[0].max_wait, null);
});
test("multiple days are returned separately, ordered by local_date", () => {
const db = setup([
s("p", 1, "2026-05-29", "10:00", true, 10, null),
s("p", 1, "2026-05-28", "10:00", true, 50, null),
s("p", 1, "2026-05-30", "10:00", true, 30, null),
]);
const rows = db.prepare(AGGREGATE_QUERY).all("p", 1, "2026-05-28") as AggregateRow[];
assert.equal(rows.length, 3);
assert.deepEqual(rows.map((r) => r.local_date), ["2026-05-28", "2026-05-29", "2026-05-30"]);
assert.deepEqual(rows.map((r) => r.max_wait), [50, 10, 30]);
});
test("local_date filter excludes earlier days", () => {
const db = setup([
s("p", 1, "2026-05-20", "10:00", true, 99, null), // before window
s("p", 1, "2026-05-29", "10:00", true, 10, null),
]);
const rows = db.prepare(AGGREGATE_QUERY).all("p", 1, "2026-05-29") as AggregateRow[];
assert.equal(rows.length, 1);
assert.equal(rows[0].local_date, "2026-05-29");
});
test("fast lane stats roll up independently of regular wait stats", () => {
const db = setup([
s("p", 1, "2026-05-29", "10:00", true, 30, 5),
s("p", 1, "2026-05-29", "10:05", true, 40, 10),
s("p", 1, "2026-05-29", "10:10", true, 50, null), // open but no FL data
]);
const rows = db.prepare(AGGREGATE_QUERY).all("p", 1, "2026-05-29") as AggregateRow[];
assert.equal(rows[0].max_fl, 10);
assert.equal(rows[0].avg_fl, 7.5); // averaged over the two non-null FL samples
assert.equal(rows[0].max_wait, 50);
});
test("parks and rides are isolated", () => {
const db = setup([
s("p1", 1, "2026-05-29", "10:00", true, 10, null),
s("p1", 2, "2026-05-29", "10:00", true, 99, null),
s("p2", 1, "2026-05-29", "10:00", true, 50, null),
]);
const r = db.prepare(AGGREGATE_QUERY).all("p1", 1, "2026-05-29") as AggregateRow[];
assert.equal(r[0].max_wait, 10);
assert.equal(r[0].sample_count, 1);
});
// ── Helper ───────────────────────────────────────────────────────────────────
function s(
parkId: string,
qtRideId: number,
localDate: string,
localTime: string,
isOpen: boolean,
waitMinutes: number | null,
fastLaneMinutes: number | null,
): Sample {
return {
parkId,
qtRideId,
recordedAt: `${localDate}T${localTime}:00Z`,
localDate,
localTime,
isOpen,
waitMinutes,
fastLaneMinutes,
};
}
+13 -6
View File
@@ -1,15 +1,18 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import Link from "next/link";
import type { LiveRidesResult, LiveRide } from "@/lib/scrapers/queuetimes"; import type { LiveRidesResult, LiveRide } from "@/lib/scrapers/queuetimes";
import { slugifyRideName } from "@/lib/ride-slug";
interface LiveRidePanelProps { interface LiveRidePanelProps {
parkId: string;
liveRides: LiveRidesResult; liveRides: LiveRidesResult;
parkOpenToday: boolean; parkOpenToday: boolean;
isWeatherDelay?: boolean; isWeatherDelay?: boolean;
} }
export function LiveRidePanel({ liveRides, parkOpenToday, isWeatherDelay }: LiveRidePanelProps) { export function LiveRidePanel({ parkId, liveRides, parkOpenToday, isWeatherDelay }: LiveRidePanelProps) {
const { rides } = liveRides; const { rides } = liveRides;
const hasCoasters = rides.some((r) => r.isCoaster); const hasCoasters = rides.some((r) => r.isCoaster);
const hasFastLane = rides.some((r) => r.hasFastLane); const hasFastLane = rides.some((r) => r.hasFastLane);
@@ -183,21 +186,22 @@ export function LiveRidePanel({ liveRides, parkOpenToday, isWeatherDelay }: Live
gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))", gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))",
gap: 6, gap: 6,
}}> }}>
{openRides.map((ride) => <RideRow key={ride.name} ride={ride} fastLaneMode={fastLaneMode} />)} {openRides.map((ride) => <RideRow key={ride.name} parkId={parkId} ride={ride} fastLaneMode={fastLaneMode} />)}
{closedRides.map((ride) => <RideRow key={ride.name} ride={ride} fastLaneMode={fastLaneMode} />)} {closedRides.map((ride) => <RideRow key={ride.name} parkId={parkId} ride={ride} fastLaneMode={fastLaneMode} />)}
</div> </div>
</div> </div>
); );
} }
function RideRow({ ride, fastLaneMode }: { ride: LiveRide; fastLaneMode: boolean }) { function RideRow({ parkId, ride, fastLaneMode }: { parkId: string; ride: LiveRide; fastLaneMode: boolean }) {
const showWait = ride.isOpen && ride.waitMinutes > 0; const showWait = ride.isOpen && ride.waitMinutes > 0;
const fastLaneActive = fastLaneMode && ride.hasFastLane; const fastLaneActive = fastLaneMode && ride.hasFastLane;
const flWait = ride.fastLaneMinutes ?? 0; const flWait = ride.fastLaneMinutes ?? 0;
const slug = ride.slug ?? slugifyRideName(ride.name);
return ( return (
<div style={{ <Link href={`/park/${parkId}/ride/${slug}`} className="ride-row-link" style={{
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "space-between", justifyContent: "space-between",
@@ -207,6 +211,9 @@ function RideRow({ ride, fastLaneMode }: { ride: LiveRide; fastLaneMode: boolean
border: `1px solid ${ride.isOpen ? "var(--color-open-border)" : "var(--color-border)"}`, border: `1px solid ${ride.isOpen ? "var(--color-open-border)" : "var(--color-border)"}`,
borderRadius: 8, borderRadius: 8,
opacity: ride.isOpen ? 1 : 0.6, opacity: ride.isOpen ? 1 : 0.6,
textDecoration: "none",
color: "inherit",
cursor: "pointer",
}}> }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}> <div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}>
<span style={{ <span style={{
@@ -277,6 +284,6 @@ function RideRow({ ride, fastLaneMode }: { ride: LiveRide; fastLaneMode: boolean
)} )}
</> </>
)} )}
</div> </Link>
); );
} }
+41
View File
@@ -0,0 +1,41 @@
interface Props {
/** Mean uptime across the window, 01. */
uptime: number;
sampleCount: number;
label: string;
}
function colorFor(uptime: number): { fg: string; bg: string; border: string } {
if (uptime >= 0.95) return { fg: "var(--color-open-text)", bg: "var(--color-open-bg)", border: "var(--color-open-border)" };
if (uptime >= 0.8) return { fg: "var(--color-closing-text)", bg: "var(--color-closing-bg)", border: "var(--color-closing-border)" };
return { fg: "var(--color-accent)", bg: "var(--color-accent-muted)", border: "var(--color-accent)" };
}
export default function UptimePill({ uptime, sampleCount, label }: Props) {
const { fg, bg, border } = colorFor(uptime);
const pct = (uptime * 100).toFixed(uptime >= 0.999 ? 0 : 1);
return (
<div style={{
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
gap: 4,
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)" }}>
{label}
</span>
<span style={{ fontSize: "1.5rem", fontWeight: 700, color: fg, lineHeight: 1 }}>
{pct}%
</span>
<span style={{ fontSize: "0.65rem", color: "var(--color-text-dim)" }}>
{sampleCount.toLocaleString()} sample{sampleCount === 1 ? "" : "s"}
</span>
</div>
);
}
+88
View File
@@ -0,0 +1,88 @@
"use client";
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from "recharts";
export interface TodaySample {
localTime: string;
isOpen: boolean;
waitMinutes: number | null;
fastLaneMinutes: number | null;
}
interface Props {
samples: TodaySample[];
hasFastLane: boolean;
}
export default function WaitTimeTodayChart({ samples, hasFastLane }: Props) {
// Map samples: closed periods → null so Recharts breaks the line.
const data = samples.map((s) => ({
time: s.localTime,
wait: s.isOpen ? s.waitMinutes : null,
fl: s.isOpen ? s.fastLaneMinutes : null,
}));
// Show every Nth tick on the X axis so labels don't overlap.
const tickInterval = Math.max(1, Math.floor(data.length / 8));
return (
<div style={{ width: "100%", height: 280 }}>
<ResponsiveContainer>
<LineChart data={data} margin={{ top: 8, right: 16, left: 0, bottom: 4 }}>
<CartesianGrid stroke="#272727" strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey="time"
stroke="#737373"
tick={{ fontSize: 11, fill: "#737373" }}
interval={tickInterval}
tickLine={false}
/>
<YAxis
stroke="#737373"
tick={{ fontSize: 11, fill: "#737373" }}
tickLine={false}
axisLine={false}
label={{ value: "min", position: "insideLeft", angle: -90, offset: 16, style: { fontSize: 10, fill: "#737373" } }}
/>
<Tooltip
contentStyle={{
background: "#1c1c1c",
border: "1px solid #333",
borderRadius: 6,
fontSize: "0.75rem",
color: "#f5f5f5",
}}
labelStyle={{ color: "#b0b0b0" }}
formatter={(value, name) => {
if (value === null || value === undefined) return ["—", name];
return [`${value} min`, name];
}}
/>
<Legend wrapperStyle={{ fontSize: "0.72rem", paddingTop: 4 }} />
<Line
name="Wait"
type="monotone"
dataKey="wait"
stroke="#4ade80"
strokeWidth={2}
dot={false}
connectNulls={false}
isAnimationActive={false}
/>
{hasFastLane && (
<Line
name="Fast Lane"
type="monotone"
dataKey="fl"
stroke="#ff4d8d"
strokeWidth={2}
dot={false}
connectNulls={false}
isAnimationActive={false}
/>
)}
</LineChart>
</ResponsiveContainer>
</div>
);
}
+66
View File
@@ -0,0 +1,66 @@
"use client";
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from "recharts";
export interface DailyAggregate {
localDate: string;
avgWait: number | null;
maxWait: number | null;
avgFastLane: number | null;
maxFastLane: number | null;
uptimePct: number;
sampleCount: number;
}
interface Props {
data: DailyAggregate[];
hasFastLane: boolean;
mode: "regular" | "fastLane";
}
function shortDay(localDate: string): string {
// "2026-05-29" → "May 29"
const [, m, d] = localDate.split("-");
const month = ["", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"][parseInt(m, 10)];
return `${month} ${parseInt(d, 10)}`;
}
export default function WeeklyStatsChart({ data, hasFastLane, mode }: Props) {
const showFastLane = mode === "fastLane" && hasFastLane;
const chartData = data.map((d) => ({
day: shortDay(d.localDate),
avg: showFastLane
? (d.avgFastLane !== null ? Math.round(d.avgFastLane) : null)
: (d.avgWait !== null ? Math.round(d.avgWait) : null),
max: showFastLane ? d.maxFastLane : d.maxWait,
}));
return (
<div style={{ width: "100%", height: 240 }}>
<ResponsiveContainer>
<BarChart data={chartData} margin={{ top: 8, right: 16, left: 0, bottom: 4 }}>
<CartesianGrid stroke="#272727" strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="day" stroke="#737373" tick={{ fontSize: 11, fill: "#737373" }} tickLine={false} />
<YAxis stroke="#737373" tick={{ fontSize: 11, fill: "#737373" }} tickLine={false} axisLine={false} />
<Tooltip
contentStyle={{
background: "#1c1c1c",
border: "1px solid #333",
borderRadius: 6,
fontSize: "0.75rem",
color: "#f5f5f5",
}}
labelStyle={{ color: "#b0b0b0" }}
formatter={(value, name) => {
if (value === null || value === undefined) return ["—", name];
return [`${value} min`, name];
}}
/>
<Legend wrapperStyle={{ fontSize: "0.72rem", paddingTop: 4 }} />
<Bar name="Avg" dataKey="avg" fill={showFastLane ? "#ff4d8d" : "#4ade80"} radius={[3, 3, 0, 0]} isAnimationActive={false} />
<Bar name="Max" dataKey="max" fill={showFastLane ? "#3d0f22" : "#0a1a0d"} stroke={showFastLane ? "#ff4d8d" : "#22c55e"} strokeWidth={1} radius={[3, 3, 0, 0]} isAnimationActive={false} />
</BarChart>
</ResponsiveContainer>
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
/**
* URL-safe slug generator for ride names.
*
* Used as a secondary key on the `rides` table — the primary key is
* (park_id, qt_ride_id) so renames don't lose history. The slug is just
* for pretty URLs.
*
* Steps:
* 1. NFD-normalize to split accented letters into base + combining mark
* 2. Strip combining marks (diacritics, U+0300U+036F)
* 3. Strip trademark symbols
* 4. Lowercase
* 5. Replace any non-alphanumeric run with a single hyphen
* 6. Trim leading/trailing hyphens
*
* Examples:
* "X²" → "x"
* "Lex Luthor: Drop of Doom" → "lex-luthor-drop-of-doom"
* "Catwoman's Whip" → "catwoman-s-whip"
* "Façade" → "facade"
* "Batman™ The Ride" → "batman-the-ride"
*/
export function slugifyRideName(name: string): string {
return name
.normalize("NFD")
.replace(/[̀-ͯ]/g, "")
.replace(/[™®©]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
+6
View File
@@ -19,6 +19,8 @@ const HEADERS = {
}; };
export interface LiveRide { export interface LiveRide {
/** Stable Queue-Times ride ID — survives renames, used as the history key. */
qtRideId: number;
name: string; name: string;
isOpen: boolean; isOpen: boolean;
waitMinutes: number; waitMinutes: number;
@@ -30,6 +32,8 @@ export interface LiveRide {
hasFastLane?: boolean; hasFastLane?: boolean;
/** Current Fast Lane wait in minutes; null = no data / walk-on. Set by the rides route. */ /** Current Fast Lane wait in minutes; null = no data / walk-on. Set by the rides route. */
fastLaneMinutes?: number | null; fastLaneMinutes?: number | null;
/** URL-safe slug derived from name. Set by the rides route. */
slug?: string;
} }
export interface LiveRidesResult { export interface LiveRidesResult {
@@ -95,6 +99,7 @@ export async function fetchLiveRides(
for (const r of land.rides ?? []) { for (const r of land.rides ?? []) {
if (!r.name) continue; if (!r.name) continue;
rides.push({ rides.push({
qtRideId: r.id,
name: r.name, name: r.name,
isOpen: r.is_open, isOpen: r.is_open,
waitMinutes: r.wait_time ?? 0, waitMinutes: r.wait_time ?? 0,
@@ -108,6 +113,7 @@ export async function fetchLiveRides(
for (const r of json.rides ?? []) { for (const r of json.rides ?? []) {
if (!r.name) continue; if (!r.name) continue;
rides.push({ rides.push({
qtRideId: r.id,
name: r.name, name: r.name,
isOpen: r.is_open, isOpen: r.is_open,
waitMinutes: r.wait_time ?? 0, waitMinutes: r.wait_time ?? 0,
+29
View File
@@ -0,0 +1,29 @@
/**
* Format a Date as YYYY-MM-DD in an IANA timezone.
*
* Uses "en-CA" because that locale natively produces ISO-style dates,
* so we don't have to reassemble parts.
*/
export function formatLocalDate(d: Date, tz: string): string {
return new Intl.DateTimeFormat("en-CA", {
timeZone: tz,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(d);
}
/**
* Format a Date as HH:MM (24-hour) in an IANA timezone.
*/
export function formatLocalTime(d: Date, tz: string): string {
const parts = new Intl.DateTimeFormat("en-GB", {
timeZone: tz,
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).formatToParts(d);
const h = parts.find((p) => p.type === "hour")?.value ?? "00";
const m = parts.find((p) => p.type === "minute")?.value ?? "00";
return `${h}:${m}`;
}
+403 -4
View File
@@ -10,7 +10,8 @@
"dependencies": { "dependencies": {
"next": "^15.3.0", "next": "^15.3.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0" "react-dom": "^19.0.0",
"recharts": "^3.8.1"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
@@ -1489,6 +1490,42 @@
"node": ">=12.4.0" "node": ">=12.4.0"
} }
}, },
"node_modules/@reduxjs/toolkit": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
"integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@reduxjs/toolkit/node_modules/immer": {
"version": "11.1.8",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz",
"integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/@rtsao/scc": { "node_modules/@rtsao/scc": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -1503,6 +1540,18 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@swc/helpers": { "node_modules/@swc/helpers": {
"version": "0.5.15", "version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -1806,6 +1855,69 @@
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1841,7 +1953,7 @@
"version": "19.2.14", "version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
@@ -1857,6 +1969,12 @@
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
}, },
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.58.0", "version": "8.58.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.0.tgz",
@@ -2852,6 +2970,15 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/color-convert": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -2898,9 +3025,130 @@
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/damerau-levenshtein": { "node_modules/damerau-levenshtein": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@@ -2980,6 +3228,12 @@
} }
} }
}, },
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/deep-is": { "node_modules/deep-is": {
"version": "0.1.4", "version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -3260,6 +3514,16 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/es-toolkit": {
"version": "1.47.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.47.0.tgz",
"integrity": "sha512-n1GuoD0WEQZMBk5tttoZSqwgyLx01oqa5XsBmCHwPyNe1S9jPBEmtR2pSgp2kJuWE3ciFZ6yRHmY4pM4C3OOkw==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.27.7", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
@@ -3722,6 +3986,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/fast-deep-equal": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -4151,6 +4421,16 @@
"node": ">= 4" "node": ">= 4"
} }
}, },
"node_modules/immer": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": { "node_modules/import-fresh": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -4193,6 +4473,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/is-array-buffer": { "node_modules/is-array-buffer": {
"version": "3.0.5", "version": "3.0.5",
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
@@ -5651,9 +5940,76 @@
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/react-redux": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
"integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/recharts": {
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz",
"integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==",
"license": "MIT",
"workspaces": [
"www"
],
"dependencies": {
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^10.1.1",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.1.1",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reflect.getprototypeof": { "node_modules/reflect.getprototypeof": {
"version": "1.0.10", "version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -5698,6 +6054,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/resolve": { "node_modules/resolve": {
"version": "2.0.0-next.6", "version": "2.0.0-next.6",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz",
@@ -6280,6 +6642,12 @@
"url": "https://opencollective.com/webpack" "url": "https://opencollective.com/webpack"
} }
}, },
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.15", "version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -6584,6 +6952,37 @@
"punycode": "^2.1.0" "punycode": "^2.1.0"
} }
}, },
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+2 -1
View File
@@ -13,7 +13,8 @@
"dependencies": { "dependencies": {
"next": "^15.3.0", "next": "^15.3.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0" "react-dom": "^19.0.0",
"recharts": "^3.8.1"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
+45
View File
@@ -0,0 +1,45 @@
/**
* Slug determinism tests. The slug is a URL-safe secondary key on the
* `rides` table — same name must always produce the same slug.
*
* Run with: npm test
*/
import { test } from "node:test";
import assert from "node:assert/strict";
import { slugifyRideName } from "../lib/ride-slug";
const CASES: [name: string, expected: string][] = [
["Goliath", "goliath"],
["X²", "x"],
["Lex Luthor: Drop of Doom", "lex-luthor-drop-of-doom"],
["Catwoman's Whip", "catwoman-s-whip"],
["Façade", "facade"],
["Le Monstre", "le-monstre"],
["Batman™ The Ride", "batman-the-ride"],
["THE RIDDLER's Revenge", "the-riddler-s-revenge"],
["Joker y Harley Quinn", "joker-y-harley-quinn"],
["Apocalypse the Ride", "apocalypse-the-ride"],
[" Leading and trailing ", "leading-and-trailing"],
["123 Numeric", "123-numeric"],
["!!!", ""],
];
for (const [name, expected] of CASES) {
test(`slugify "${name}" → "${expected}"`, () => {
assert.equal(slugifyRideName(name), expected);
});
}
test("slug is idempotent — slugifying the result yields the same value", () => {
for (const [name] of CASES) {
const once = slugifyRideName(name);
if (once === "") continue;
assert.equal(slugifyRideName(once), once, `Expected idempotent slug for "${name}"`);
}
});
test("same name always produces the same slug", () => {
const name = "Twisted Cyclone";
assert.equal(slugifyRideName(name), slugifyRideName(name));
});
+110
View File
@@ -0,0 +1,110 @@
/**
* Timezone bucketing tests for ride wait samples.
*
* Samples are stored with UTC `recorded_at` and pre-bucketed `local_date`
* + `local_time` columns in the park's IANA timezone. These columns are what
* the aggregation queries group on, so the bucketing has to be DST-safe
* across spring forward and fall back.
*
* Run with: npm test
*/
import { test } from "node:test";
import assert from "node:assert/strict";
import { formatLocalDate, formatLocalTime } from "../lib/timezone";
// ── Basic cases ──────────────────────────────────────────────────────────────
test("formatLocalDate produces YYYY-MM-DD in the target zone", () => {
// 2026-05-29 17:00 UTC = 2026-05-29 13:00 ET = 2026-05-29 10:00 PT
const d = new Date("2026-05-29T17:00:00Z");
assert.equal(formatLocalDate(d, "America/New_York"), "2026-05-29");
assert.equal(formatLocalDate(d, "America/Los_Angeles"), "2026-05-29");
});
test("formatLocalDate rolls to previous day for late UTC times in west zones", () => {
// 2026-05-30 04:00 UTC = 2026-05-29 21:00 PT
const d = new Date("2026-05-30T04:00:00Z");
assert.equal(formatLocalDate(d, "America/Los_Angeles"), "2026-05-29");
assert.equal(formatLocalDate(d, "America/New_York"), "2026-05-30");
});
test("formatLocalTime produces HH:MM in 24-hour format", () => {
// 2026-05-29 23:30 UTC = 2026-05-29 19:30 ET = 2026-05-29 16:30 PT
const d = new Date("2026-05-29T23:30:00Z");
assert.equal(formatLocalTime(d, "America/New_York"), "19:30");
assert.equal(formatLocalTime(d, "America/Los_Angeles"), "16:30");
});
// ── DST: spring forward (2026-03-08 in US: 2 AM → 3 AM) ──────────────────────
test("spring forward: time before transition shows in standard offset", () => {
// 2026-03-08 07:30 UTC = 2026-03-08 02:30 EST (before transition completes)
// Actually: at 2026-03-08 07:00 UTC = 2026-03-08 03:00 EDT (after transition)
// Use a clearly-before time:
const before = new Date("2026-03-08T06:30:00Z"); // 01:30 EST
assert.equal(formatLocalDate(before, "America/New_York"), "2026-03-08");
assert.equal(formatLocalTime(before, "America/New_York"), "01:30");
});
test("spring forward: time after transition shows in DST offset", () => {
// 2026-03-08 07:30 UTC = 2026-03-08 03:30 EDT (DST in effect)
const after = new Date("2026-03-08T07:30:00Z");
assert.equal(formatLocalDate(after, "America/New_York"), "2026-03-08");
assert.equal(formatLocalTime(after, "America/New_York"), "03:30");
});
test("spring forward: local_date is consistent across the missing hour", () => {
// The skipped hour is 02:0003:00 EST. Samples bracketing it should still
// bucket to the same local_date.
const before = new Date("2026-03-08T06:30:00Z"); // 01:30 EST
const after = new Date("2026-03-08T07:30:00Z"); // 03:30 EDT
assert.equal(formatLocalDate(before, "America/New_York"), formatLocalDate(after, "America/New_York"));
});
// ── DST: fall back (2026-11-01 in US: 2 AM → 1 AM) ────────────────────────────
test("fall back: time before transition shows in DST offset", () => {
// 2026-11-01 05:30 UTC = 2026-11-01 01:30 EDT (before fall-back at 2 AM EDT)
const beforeFallback = new Date("2026-11-01T05:30:00Z");
assert.equal(formatLocalDate(beforeFallback, "America/New_York"), "2026-11-01");
assert.equal(formatLocalTime(beforeFallback, "America/New_York"), "01:30");
});
test("fall back: time after transition shows in standard offset", () => {
// 2026-11-01 07:30 UTC = 2026-11-01 02:30 EST (after fall-back)
const afterFallback = new Date("2026-11-01T07:30:00Z");
assert.equal(formatLocalDate(afterFallback, "America/New_York"), "2026-11-01");
assert.equal(formatLocalTime(afterFallback, "America/New_York"), "02:30");
});
test("fall back: the same local hour repeats but local_date stays stable", () => {
// 2026-11-01 05:30 UTC = 01:30 EDT
// 2026-11-01 06:30 UTC = 01:30 EST (second occurrence of 01:30 — fall back)
const first = new Date("2026-11-01T05:30:00Z");
const second = new Date("2026-11-01T06:30:00Z");
assert.equal(formatLocalTime(first, "America/New_York"), "01:30");
assert.equal(formatLocalTime(second, "America/New_York"), "01:30");
assert.equal(formatLocalDate(first, "America/New_York"), formatLocalDate(second, "America/New_York"));
});
// ── Cross-zone: a single UTC moment buckets differently per park ─────────────
test("midnight UTC straddles the local-date boundary for west-coast parks", () => {
const utcMidnight = new Date("2026-06-15T00:00:00Z");
// Eastern: still 2026-06-14 20:00
assert.equal(formatLocalDate(utcMidnight, "America/New_York"), "2026-06-14");
// Pacific: 2026-06-14 17:00
assert.equal(formatLocalDate(utcMidnight, "America/Los_Angeles"), "2026-06-14");
});
test("Mountain and Central parks bucket distinctly during the late-evening hour", () => {
// 2026-07-04 04:30 UTC
// = 2026-07-03 21:30 MDT (UTC-6) → date 2026-07-03
// = 2026-07-03 23:30 CDT (UTC-5) → date 2026-07-03
const d = new Date("2026-07-04T04:30:00Z");
assert.equal(formatLocalDate(d, "America/Denver"), "2026-07-03");
assert.equal(formatLocalDate(d, "America/Chicago"), "2026-07-03");
assert.equal(formatLocalTime(d, "America/Denver"), "22:30");
assert.equal(formatLocalTime(d, "America/Chicago"), "23:30");
});