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:
@@ -110,6 +110,15 @@
|
||||
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 ───────────────────────── */
|
||||
/* Mobile: fixed uniform rows so narrow columns don't cause height variance */
|
||||
.park-calendar-grid {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,8 @@
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "tsx --test tests/*.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^2.0.0",
|
||||
|
||||
@@ -28,6 +28,41 @@ export function getDb(): Database.Database {
|
||||
} catch {
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -161,3 +161,222 @@ export function getParkDayCount(): number {
|
||||
export function transact(fn: () => void): void {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { startScheduler } from "./services/scheduler";
|
||||
import calendarRoutes from "./routes/calendar";
|
||||
import parksRoutes from "./routes/parks";
|
||||
import ridesRoutes from "./routes/rides";
|
||||
import rideHistoryRoutes from "./routes/ride-history";
|
||||
import statusRoutes from "./routes/status";
|
||||
import scrapeRoutes from "./routes/scrape";
|
||||
|
||||
@@ -22,6 +23,7 @@ app.use("*", cors());
|
||||
app.route("/api/calendar", calendarRoutes);
|
||||
app.route("/api/parks", parksRoutes);
|
||||
app.route("/api/parks", ridesRoutes);
|
||||
app.route("/api/parks", rideHistoryRoutes);
|
||||
app.route("/api/status", statusRoutes);
|
||||
app.route("/api/scrape", scrapeRoutes);
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -7,12 +7,9 @@ import { fetchLiveRides } from "../../../lib/scrapers/queuetimes";
|
||||
import { scrapeRidesForDay } from "../../../lib/scrapers/sixflags";
|
||||
import { fetchFastLaneWaits, lookupFastLane } from "../../../lib/scrapers/sixflags-waittimes";
|
||||
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 { 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();
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Hono } from "hono";
|
||||
import { scrapeToday, scrapeCurrentMonth, scrapeUpcomingMonths, scrapeFullYear } from "../services/scraper";
|
||||
import { sampleAllOpenParks } from "../services/wait-sampler";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
@@ -23,8 +24,11 @@ app.post("/trigger", async (c) => {
|
||||
case "force":
|
||||
result = await scrapeFullYear(true);
|
||||
break;
|
||||
case "samples":
|
||||
result = await sampleAllOpenParks();
|
||||
break;
|
||||
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);
|
||||
|
||||
@@ -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);
|
||||
@@ -1,5 +1,6 @@
|
||||
import cron from "node-cron";
|
||||
import { scrapeToday, scrapeCurrentMonth, scrapeUpcomingMonths, scrapeFullYear } from "./scraper";
|
||||
import { sampleAllOpenParks } from "./wait-sampler";
|
||||
import { getParkDayCount } from "../db/queries";
|
||||
|
||||
let initialized = false;
|
||||
@@ -32,11 +33,25 @@ export function startScheduler(): void {
|
||||
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(" tier-1: today — hourly (Mar-Dec)");
|
||||
console.log(" tier-2: current month — every 6h");
|
||||
console.log(" tier-3: upcoming — 3 AM + 3 PM");
|
||||
console.log(" tier-4: full year — 3 AM daily");
|
||||
console.log(" tier-5: wait samples — every 5 min");
|
||||
|
||||
const existingRows = getParkDayCount();
|
||||
if (existingRows < 50) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -1,15 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import type { LiveRidesResult, LiveRide } from "@/lib/scrapers/queuetimes";
|
||||
import { slugifyRideName } from "@/lib/ride-slug";
|
||||
|
||||
interface LiveRidePanelProps {
|
||||
parkId: string;
|
||||
liveRides: LiveRidesResult;
|
||||
parkOpenToday: boolean;
|
||||
isWeatherDelay?: boolean;
|
||||
}
|
||||
|
||||
export function LiveRidePanel({ liveRides, parkOpenToday, isWeatherDelay }: LiveRidePanelProps) {
|
||||
export function LiveRidePanel({ parkId, liveRides, parkOpenToday, isWeatherDelay }: LiveRidePanelProps) {
|
||||
const { rides } = liveRides;
|
||||
const hasCoasters = rides.some((r) => r.isCoaster);
|
||||
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))",
|
||||
gap: 6,
|
||||
}}>
|
||||
{openRides.map((ride) => <RideRow key={ride.name} ride={ride} fastLaneMode={fastLaneMode} />)}
|
||||
{closedRides.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} parkId={parkId} ride={ride} fastLaneMode={fastLaneMode} />)}
|
||||
</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 fastLaneActive = fastLaneMode && ride.hasFastLane;
|
||||
const flWait = ride.fastLaneMinutes ?? 0;
|
||||
const slug = ride.slug ?? slugifyRideName(ride.name);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
<Link href={`/park/${parkId}/ride/${slug}`} className="ride-row-link" style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
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)"}`,
|
||||
borderRadius: 8,
|
||||
opacity: ride.isOpen ? 1 : 0.6,
|
||||
textDecoration: "none",
|
||||
color: "inherit",
|
||||
cursor: "pointer",
|
||||
}}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}>
|
||||
<span style={{
|
||||
@@ -277,6 +284,6 @@ function RideRow({ ride, fastLaneMode }: { ride: LiveRide; fastLaneMode: boolean
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
interface Props {
|
||||
/** Mean uptime across the window, 0–1. */
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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+0300–U+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, "");
|
||||
}
|
||||
@@ -19,6 +19,8 @@ const HEADERS = {
|
||||
};
|
||||
|
||||
export interface LiveRide {
|
||||
/** Stable Queue-Times ride ID — survives renames, used as the history key. */
|
||||
qtRideId: number;
|
||||
name: string;
|
||||
isOpen: boolean;
|
||||
waitMinutes: number;
|
||||
@@ -30,6 +32,8 @@ export interface LiveRide {
|
||||
hasFastLane?: boolean;
|
||||
/** Current Fast Lane wait in minutes; null = no data / walk-on. Set by the rides route. */
|
||||
fastLaneMinutes?: number | null;
|
||||
/** URL-safe slug derived from name. Set by the rides route. */
|
||||
slug?: string;
|
||||
}
|
||||
|
||||
export interface LiveRidesResult {
|
||||
@@ -95,6 +99,7 @@ export async function fetchLiveRides(
|
||||
for (const r of land.rides ?? []) {
|
||||
if (!r.name) continue;
|
||||
rides.push({
|
||||
qtRideId: r.id,
|
||||
name: r.name,
|
||||
isOpen: r.is_open,
|
||||
waitMinutes: r.wait_time ?? 0,
|
||||
@@ -108,6 +113,7 @@ export async function fetchLiveRides(
|
||||
for (const r of json.rides ?? []) {
|
||||
if (!r.name) continue;
|
||||
rides.push({
|
||||
qtRideId: r.id,
|
||||
name: r.name,
|
||||
isOpen: r.is_open,
|
||||
waitMinutes: r.wait_time ?? 0,
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
Generated
+403
-4
@@ -10,7 +10,8 @@
|
||||
"dependencies": {
|
||||
"next": "^15.3.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
"react-dom": "^19.0.0",
|
||||
"recharts": "^3.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
@@ -1489,6 +1490,42 @@
|
||||
"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": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||
@@ -1503,6 +1540,18 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "0.5.15",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||
@@ -1806,6 +1855,69 @@
|
||||
"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": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@@ -1841,7 +1953,7 @@
|
||||
"version": "19.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
@@ -1857,6 +1969,12 @@
|
||||
"@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": {
|
||||
"version": "8.58.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.0.tgz",
|
||||
@@ -2852,6 +2970,15 @@
|
||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||
"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": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
@@ -2898,9 +3025,130 @@
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"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": {
|
||||
"version": "1.0.8",
|
||||
"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": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||
@@ -3260,6 +3514,16 @@
|
||||
"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": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
|
||||
@@ -3722,6 +3986,12 @@
|
||||
"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": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
@@ -4151,6 +4421,16 @@
|
||||
"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": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
@@ -4193,6 +4473,15 @@
|
||||
"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": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
||||
@@ -5651,9 +5940,76 @@
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||
@@ -5698,6 +6054,12 @@
|
||||
"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": {
|
||||
"version": "2.0.0-next.6",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz",
|
||||
@@ -6280,6 +6642,12 @@
|
||||
"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": {
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
@@ -6584,6 +6952,37 @@
|
||||
"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": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
||||
+2
-1
@@ -13,7 +13,8 @@
|
||||
"dependencies": {
|
||||
"next": "^15.3.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
"react-dom": "^19.0.0",
|
||||
"recharts": "^3.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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:00–03: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");
|
||||
});
|
||||
Reference in New Issue
Block a user