feat: add Fast Lane wait times toggle on park pages
Build and Deploy / Build & Push (push) Successful in 1m3s
Build and Deploy / Build & Push (push) Successful in 1m3s
Join Fast Lane waits from the Six Flags /wait-times endpoint onto Queue-Times rides by name. A new toggle on the live ride panel swaps the shown wait to the Fast Lane number; regular waits and open status still come from Queue-Times. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -97,6 +97,7 @@ export default async function ParkPage({ params, searchParams }: PageProps) {
|
|||||||
{/* ── Ride Status ─────────────────────────────────────────────────── */}
|
{/* ── Ride Status ─────────────────────────────────────────────────── */}
|
||||||
<section>
|
<section>
|
||||||
<SectionHeading aside={liveRides ? (
|
<SectionHeading aside={liveRides ? (
|
||||||
|
<span style={{ display: "flex", alignItems: "center", gap: 6, flexWrap: "wrap", justifyContent: "flex-end" }}>
|
||||||
<a
|
<a
|
||||||
href="https://queue-times.com"
|
href="https://queue-times.com"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -114,6 +115,12 @@ export default async function ParkPage({ params, searchParams }: PageProps) {
|
|||||||
>
|
>
|
||||||
via queue-times.com
|
via queue-times.com
|
||||||
</a>
|
</a>
|
||||||
|
{liveRides.rides.some((r) => r.hasFastLane) && (
|
||||||
|
<span style={{ fontSize: "0.68rem", color: "var(--color-text-dim)" }}>
|
||||||
|
· Fast Lane via sixflags.com
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
) : undefined}>
|
) : undefined}>
|
||||||
Rides
|
Rides
|
||||||
{liveRides ? (
|
{liveRides ? (
|
||||||
|
|||||||
@@ -5,11 +5,14 @@ import { getCoasterSet } from "../../../lib/coaster-data";
|
|||||||
import { getTodayLocal, isWithinOperatingWindow } from "../../../lib/env";
|
import { getTodayLocal, isWithinOperatingWindow } from "../../../lib/env";
|
||||||
import { fetchLiveRides } from "../../../lib/scrapers/queuetimes";
|
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 { getDayData } from "../db/queries";
|
import { getDayData } from "../db/queries";
|
||||||
import { TtlCache } from "../services/cache";
|
import { TtlCache } from "../services/cache";
|
||||||
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 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();
|
||||||
|
|
||||||
@@ -41,6 +44,30 @@ app.get("/:id/rides", async (c) => {
|
|||||||
rides: liveRides.rides.map((r) => ({ ...r, isOpen: false, waitMinutes: 0 })),
|
rides: liveRides.rides.map((r) => ({ ...r, isOpen: false, waitMinutes: 0 })),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Join Fast Lane waits (Six Flags /wait-times) onto the Queue-Times rides by name.
|
||||||
|
if (liveRides) {
|
||||||
|
let fastLane = fastLaneCache.get(id);
|
||||||
|
if (fastLane === null) {
|
||||||
|
fastLane = await fetchFastLaneWaits(park.apiId).catch(() => null);
|
||||||
|
if (fastLane) fastLaneCache.set(id, fastLane);
|
||||||
|
}
|
||||||
|
if (fastLane) {
|
||||||
|
const fl = fastLane;
|
||||||
|
liveRides = {
|
||||||
|
...liveRides,
|
||||||
|
rides: liveRides.rides.map((r) => {
|
||||||
|
const match = lookupFastLane(r.name, fl);
|
||||||
|
if (!match) return r;
|
||||||
|
return {
|
||||||
|
...r,
|
||||||
|
hasFastLane: match.hasFastLane,
|
||||||
|
fastLaneMinutes: r.isOpen ? match.fastLaneMinutes : null,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isWeatherDelay =
|
const isWeatherDelay =
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ interface LiveRidePanelProps {
|
|||||||
export function LiveRidePanel({ liveRides, parkOpenToday, isWeatherDelay }: LiveRidePanelProps) {
|
export function LiveRidePanel({ 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 [coastersOnly, setCoastersOnly] = useState(false);
|
const [coastersOnly, setCoastersOnly] = useState(false);
|
||||||
|
const [fastLaneMode, setFastLaneMode] = useState(false);
|
||||||
|
|
||||||
// Pre-select coaster filter if Coaster Mode is enabled on the homepage.
|
// Pre-select coaster filter if Coaster Mode is enabled on the homepage.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -21,6 +23,21 @@ export function LiveRidePanel({ liveRides, parkOpenToday, isWeatherDelay }: Live
|
|||||||
}
|
}
|
||||||
}, [hasCoasters]);
|
}, [hasCoasters]);
|
||||||
|
|
||||||
|
// Restore Fast Lane mode from a previous visit.
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasFastLane && localStorage.getItem("fastLaneMode") === "true") {
|
||||||
|
setFastLaneMode(true);
|
||||||
|
}
|
||||||
|
}, [hasFastLane]);
|
||||||
|
|
||||||
|
const toggleFastLane = () => {
|
||||||
|
setFastLaneMode((v) => {
|
||||||
|
const next = !v;
|
||||||
|
localStorage.setItem("fastLaneMode", String(next));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const visible = coastersOnly ? rides.filter((r) => r.isCoaster) : rides;
|
const visible = coastersOnly ? rides.filter((r) => r.isCoaster) : rides;
|
||||||
const openRides = visible.filter((r) => r.isOpen);
|
const openRides = visible.filter((r) => r.isOpen);
|
||||||
const closedRides = visible.filter((r) => !r.isOpen);
|
const closedRides = visible.filter((r) => !r.isOpen);
|
||||||
@@ -94,12 +111,44 @@ export function LiveRidePanel({ liveRides, parkOpenToday, isWeatherDelay }: Live
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Toggle group — pushed to the right */}
|
||||||
|
{(hasCoasters || hasFastLane) && (
|
||||||
|
<div style={{ marginLeft: "auto", display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||||
|
{/* Fast Lane toggle — swaps shown waits to Fast Lane numbers */}
|
||||||
|
{hasFastLane && (
|
||||||
|
<button
|
||||||
|
onClick={toggleFastLane}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 5,
|
||||||
|
padding: "4px 12px",
|
||||||
|
borderRadius: 20,
|
||||||
|
border: fastLaneMode
|
||||||
|
? "1px solid var(--color-accent)"
|
||||||
|
: "1px solid var(--color-border)",
|
||||||
|
background: fastLaneMode
|
||||||
|
? "var(--color-accent-muted)"
|
||||||
|
: "var(--color-surface)",
|
||||||
|
color: fastLaneMode
|
||||||
|
? "var(--color-accent)"
|
||||||
|
: "var(--color-text-muted)",
|
||||||
|
fontSize: "0.72rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "background 150ms ease, border-color 150ms ease, color 150ms ease",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
⚡ Fast Lane
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Coaster toggle — only shown when the park has categorised coasters */}
|
{/* Coaster toggle — only shown when the park has categorised coasters */}
|
||||||
{hasCoasters && (
|
{hasCoasters && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setCoastersOnly((v) => !v)}
|
onClick={() => setCoastersOnly((v) => !v)}
|
||||||
style={{
|
style={{
|
||||||
marginLeft: "auto",
|
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: 5,
|
gap: 5,
|
||||||
@@ -125,6 +174,8 @@ export function LiveRidePanel({ liveRides, parkOpenToday, isWeatherDelay }: Live
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* ── Ride grid ────────────────────────────────────────────────────── */}
|
{/* ── Ride grid ────────────────────────────────────────────────────── */}
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -132,16 +183,18 @@ 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} />)}
|
{openRides.map((ride) => <RideRow key={ride.name} ride={ride} fastLaneMode={fastLaneMode} />)}
|
||||||
{closedRides.map((ride) => <RideRow key={ride.name} ride={ride} />)}
|
{closedRides.map((ride) => <RideRow key={ride.name} ride={ride} fastLaneMode={fastLaneMode} />)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function RideRow({ ride }: { ride: LiveRide }) {
|
function RideRow({ ride, fastLaneMode }: { ride: LiveRide; fastLaneMode: boolean }) {
|
||||||
const showWait = ride.isOpen && ride.waitMinutes > 0;
|
const showWait = ride.isOpen && ride.waitMinutes > 0;
|
||||||
|
const fastLaneActive = fastLaneMode && ride.hasFastLane;
|
||||||
|
const flWait = ride.fastLaneMinutes ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -174,6 +227,32 @@ function RideRow({ ride }: { ride: LiveRide }) {
|
|||||||
{ride.name}
|
{ride.name}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Fast Lane mode: swap the shown wait to the Fast Lane number */}
|
||||||
|
{fastLaneMode ? (
|
||||||
|
ride.isOpen && fastLaneActive ? (
|
||||||
|
<span style={{
|
||||||
|
fontSize: "0.72rem",
|
||||||
|
color: "var(--color-accent)",
|
||||||
|
fontWeight: 600,
|
||||||
|
flexShrink: 0,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}>
|
||||||
|
{flWait > 0 ? `⚡ ${flWait} min` : "⚡ walk-on"}
|
||||||
|
</span>
|
||||||
|
) : ride.isOpen ? (
|
||||||
|
<span style={{
|
||||||
|
fontSize: "0.68rem",
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
fontWeight: 500,
|
||||||
|
flexShrink: 0,
|
||||||
|
opacity: 0.7,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}>
|
||||||
|
no Fast Lane
|
||||||
|
</span>
|
||||||
|
) : null
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
{showWait && (
|
{showWait && (
|
||||||
<span style={{
|
<span style={{
|
||||||
fontSize: "0.72rem",
|
fontSize: "0.72rem",
|
||||||
@@ -196,6 +275,8 @@ function RideRow({ ride }: { ride: LiveRide }) {
|
|||||||
walk-on
|
walk-on
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ export interface LiveRide {
|
|||||||
lastUpdated: string; // ISO 8601
|
lastUpdated: string; // ISO 8601
|
||||||
/** True when the ride name appears in the RCDB coaster list for this park. */
|
/** True when the ride name appears in the RCDB coaster list for this park. */
|
||||||
isCoaster: boolean;
|
isCoaster: boolean;
|
||||||
|
/** True when the ride supports Fast Lane (from the Six Flags /wait-times endpoint).
|
||||||
|
* Set by the rides route, not the Queue-Times scraper. */
|
||||||
|
hasFastLane?: boolean;
|
||||||
|
/** Current Fast Lane wait in minutes; null = no data / walk-on. Set by the rides route. */
|
||||||
|
fastLaneMinutes?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LiveRidesResult {
|
export interface LiveRidesResult {
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
/**
|
||||||
|
* Six Flags live wait-times scraper — Fast Lane data.
|
||||||
|
*
|
||||||
|
* API: https://d18car1k0ff81h.cloudfront.net/wait-times/park/{apiId}
|
||||||
|
* Sibling of the operating-hours endpoint in sixflags.ts. Exposes both a
|
||||||
|
* regular and a Fast Lane wait per ride. We only consume the Fast Lane side;
|
||||||
|
* regular waits + open status keep coming from Queue-Times.
|
||||||
|
*
|
||||||
|
* The response has no isOpen field, so Fast Lane numbers are joined onto the
|
||||||
|
* Queue-Times ride list by name (see lookupFastLane) and gated on the
|
||||||
|
* Queue-Times open status by the caller.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { normalizeForMatch } from "../coaster-match";
|
||||||
|
|
||||||
|
const WAIT_TIMES_BASE = "https://d18car1k0ff81h.cloudfront.net/wait-times/park";
|
||||||
|
|
||||||
|
// Conjunctions that join two ride names rather than extend one subtitle —
|
||||||
|
// kept in sync with coaster-match.ts so the prefix match stays symmetric.
|
||||||
|
const CONJUNCTIONS = new Set(["y", "and", "&", "with", "de", "del", "e", "et"]);
|
||||||
|
|
||||||
|
const HEADERS = {
|
||||||
|
"User-Agent":
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
|
||||||
|
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
|
||||||
|
Accept: "application/json",
|
||||||
|
"Accept-Language": "en-US,en;q=0.9",
|
||||||
|
Referer: "https://www.sixflags.com/",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface WTWaittime {
|
||||||
|
createdDateTime: string; // "" when no data
|
||||||
|
waitTime: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WTRideDetail {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
isFastLane: boolean;
|
||||||
|
regularWaittime?: WTWaittime;
|
||||||
|
fastlaneWaittime?: WTWaittime;
|
||||||
|
fimsId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WTVenue {
|
||||||
|
venueId: number;
|
||||||
|
venueName: string;
|
||||||
|
details?: WTRideDetail[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WTResponse {
|
||||||
|
parkId?: number;
|
||||||
|
parkName?: string;
|
||||||
|
venues?: WTVenue[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FastLaneEntry {
|
||||||
|
norm: string;
|
||||||
|
compact: string;
|
||||||
|
isFastLane: boolean;
|
||||||
|
/** Current Fast Lane wait in minutes; null when the endpoint has no data. */
|
||||||
|
fastLaneMinutes: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FastLaneResult {
|
||||||
|
entries: FastLaneEntry[];
|
||||||
|
/** ISO timestamp of when we fetched the data. */
|
||||||
|
fetchedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a raw /wait-times response into Fast Lane entries. Pure and
|
||||||
|
* network-free so it can be unit tested. Returns null when no ride rows
|
||||||
|
* are present.
|
||||||
|
*/
|
||||||
|
export function parseWaitTimes(json: WTResponse): FastLaneResult | null {
|
||||||
|
const entries: FastLaneEntry[] = [];
|
||||||
|
for (const venue of (json.venues ?? []).filter((v) => v.venueName === "Rides")) {
|
||||||
|
for (const d of venue.details ?? []) {
|
||||||
|
if (!d.name) continue;
|
||||||
|
const norm = normalizeForMatch(d.name);
|
||||||
|
entries.push({
|
||||||
|
norm,
|
||||||
|
compact: norm.replace(/\s/g, ""),
|
||||||
|
isFastLane: Boolean(d.isFastLane),
|
||||||
|
fastLaneMinutes: d.fastlaneWaittime?.createdDateTime
|
||||||
|
? d.fastlaneWaittime.waitTime
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entries.length === 0) return null;
|
||||||
|
|
||||||
|
return { entries, fetchedAt: new Date().toISOString() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch Fast Lane wait times for a park. Returns null on any failure
|
||||||
|
* (network/parse/timeout) or when the response carries no rides — same
|
||||||
|
* contract as fetchLiveRides.
|
||||||
|
*
|
||||||
|
* Pass revalidate (seconds) to control Next.js ISR cache lifetime.
|
||||||
|
*/
|
||||||
|
export async function fetchFastLaneWaits(
|
||||||
|
apiId: number,
|
||||||
|
revalidate = 300,
|
||||||
|
): Promise<FastLaneResult | null> {
|
||||||
|
const url = `${WAIT_TIMES_BASE}/${apiId}`;
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: HEADERS,
|
||||||
|
next: { revalidate },
|
||||||
|
signal: AbortSignal.timeout(10_000),
|
||||||
|
} as RequestInit & { next: { revalidate: number } });
|
||||||
|
|
||||||
|
if (!res.ok) return null;
|
||||||
|
|
||||||
|
return parseWaitTimes((await res.json()) as WTResponse);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the Fast Lane data for a ride by name. Mirrors the isCoasterMatch
|
||||||
|
* strategy (exact normalized → compact ≥5 → prefix ≥5 with conjunction guard)
|
||||||
|
* so Queue-Times and Six Flags name conventions line up.
|
||||||
|
*
|
||||||
|
* Returns the matched ride's Fast Lane info, or null when no ride matches.
|
||||||
|
*/
|
||||||
|
export function lookupFastLane(
|
||||||
|
rideName: string,
|
||||||
|
result: FastLaneResult,
|
||||||
|
): { hasFastLane: boolean; fastLaneMinutes: number | null } | null {
|
||||||
|
const norm = normalizeForMatch(rideName);
|
||||||
|
const compact = norm.replace(/\s/g, "");
|
||||||
|
|
||||||
|
let match: FastLaneEntry | undefined = result.entries.find((e) => e.norm === norm);
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
for (const e of result.entries) {
|
||||||
|
// Compact comparison
|
||||||
|
if (compact.length >= 5 && e.compact === compact) {
|
||||||
|
match = e;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Prefix comparison
|
||||||
|
const shorter = norm.length <= e.norm.length ? norm : e.norm;
|
||||||
|
const longer = norm.length <= e.norm.length ? e.norm : norm;
|
||||||
|
if (shorter.length >= 5 && longer.startsWith(shorter)) {
|
||||||
|
const nextWord = longer.slice(shorter.length).trim().split(" ")[0];
|
||||||
|
if (!CONJUNCTIONS.has(nextWord)) {
|
||||||
|
match = e;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!match) return null;
|
||||||
|
return { hasFastLane: match.isFastLane, fastLaneMinutes: match.fastLaneMinutes };
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* Fast Lane name-join tests.
|
||||||
|
*
|
||||||
|
* The Six Flags /wait-times endpoint and Queue-Times use slightly different
|
||||||
|
* ride name conventions, so Fast Lane waits are joined onto Queue-Times rides
|
||||||
|
* by normalized name. These cases lock that join behaviour.
|
||||||
|
*
|
||||||
|
* Run with: npm test
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { parseWaitTimes, lookupFastLane } from "../lib/scrapers/sixflags-waittimes";
|
||||||
|
import type { WTResponse } from "../lib/scrapers/sixflags-waittimes";
|
||||||
|
|
||||||
|
function ride(
|
||||||
|
name: string,
|
||||||
|
isFastLane: boolean,
|
||||||
|
fastLaneMinutes: number | null,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
id: 1,
|
||||||
|
name,
|
||||||
|
isFastLane,
|
||||||
|
regularWaittime: { createdDateTime: "May 29, 2026 19:00:00", waitTime: 20 },
|
||||||
|
fastlaneWaittime:
|
||||||
|
fastLaneMinutes === null
|
||||||
|
? { createdDateTime: "", waitTime: 0 }
|
||||||
|
: { createdDateTime: "May 29, 2026 19:00:00", waitTime: fastLaneMinutes },
|
||||||
|
fimsId: "RIDE-906-00001",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function result(...rides: Record<string, unknown>[]) {
|
||||||
|
const json: WTResponse = {
|
||||||
|
parkId: 906,
|
||||||
|
venues: [{ venueId: 1, venueName: "Rides", details: rides as never }],
|
||||||
|
};
|
||||||
|
const r = parseWaitTimes(json);
|
||||||
|
assert.ok(r, "expected parseWaitTimes to return a result");
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Name joins across QT ↔ SF naming quirks ──────────────────────────────────
|
||||||
|
|
||||||
|
test("matches across trademark symbols, THE prefix, possessives", () => {
|
||||||
|
const r = result(
|
||||||
|
ride("Batman: The Ride", true, 5),
|
||||||
|
ride("Riddler's Revenge", true, 10),
|
||||||
|
ride("Apocalypse the Ride", true, 15),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Queue-Times-style names on the left should resolve to the SF entries.
|
||||||
|
assert.deepEqual(lookupFastLane("BATMAN™ The Ride", r), {
|
||||||
|
hasFastLane: true,
|
||||||
|
fastLaneMinutes: 5,
|
||||||
|
});
|
||||||
|
assert.deepEqual(lookupFastLane("THE RIDDLER™'s Revenge", r), {
|
||||||
|
hasFastLane: true,
|
||||||
|
fastLaneMinutes: 10,
|
||||||
|
});
|
||||||
|
// Prefix match: "Apocalypse" is a prefix of "Apocalypse the Ride".
|
||||||
|
assert.deepEqual(lookupFastLane("Apocalypse", r), {
|
||||||
|
hasFastLane: true,
|
||||||
|
fastLaneMinutes: 15,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a non-Fast-Lane ride resolves to hasFastLane: false", () => {
|
||||||
|
const r = result(ride("Bucaneer", false, null));
|
||||||
|
assert.deepEqual(lookupFastLane("Bucaneer", r), {
|
||||||
|
hasFastLane: false,
|
||||||
|
fastLaneMinutes: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("empty fastlane createdDateTime yields fastLaneMinutes: null", () => {
|
||||||
|
const r = result(ride("Batman: The Ride", true, null));
|
||||||
|
assert.deepEqual(lookupFastLane("Batman: The Ride", r), {
|
||||||
|
hasFastLane: true,
|
||||||
|
fastLaneMinutes: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a ride absent from SF data returns null", () => {
|
||||||
|
const r = result(ride("Apocalypse the Ride", true, 15));
|
||||||
|
assert.equal(lookupFastLane("Some Other Coaster", r), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("conjunction guard: compound name does not match a single ride", () => {
|
||||||
|
const r = result(ride("Joker", true, 25));
|
||||||
|
// "Joker y Harley Quinn" is a different (compound) ride, not a Joker subtitle.
|
||||||
|
assert.equal(lookupFastLane("Joker y Harley Quinn", r), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseWaitTimes returns null when no ride rows present", () => {
|
||||||
|
assert.equal(parseWaitTimes({ parkId: 1, venues: [] }), null);
|
||||||
|
assert.equal(
|
||||||
|
parseWaitTimes({ parkId: 1, venues: [{ venueId: 9, venueName: "Restaurants", details: [] }] }),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user