From bfe099322f45f221796522e650bd9742ec66ede2 Mon Sep 17 00:00:00 2001 From: josh Date: Fri, 29 May 2026 22:51:52 -0400 Subject: [PATCH] feat: add Fast Lane wait times toggle on park pages 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 --- app/park/[id]/page.tsx | 41 ++++--- backend/src/routes/rides.ts | 27 +++++ components/LiveRidePanel.tsx | 187 +++++++++++++++++++++-------- lib/scrapers/queuetimes.ts | 5 + lib/scrapers/sixflags-waittimes.ts | 163 +++++++++++++++++++++++++ tests/fast-lane-matching.test.ts | 102 ++++++++++++++++ 6 files changed, 455 insertions(+), 70 deletions(-) create mode 100644 lib/scrapers/sixflags-waittimes.ts create mode 100644 tests/fast-lane-matching.test.ts diff --git a/app/park/[id]/page.tsx b/app/park/[id]/page.tsx index b2e2fd6..ca27f75 100644 --- a/app/park/[id]/page.tsx +++ b/app/park/[id]/page.tsx @@ -97,23 +97,30 @@ export default async function ParkPage({ params, searchParams }: PageProps) { {/* ── Ride Status ─────────────────────────────────────────────────── */}
- via queue-times.com - + + + via queue-times.com + + {liveRides.rides.some((r) => r.hasFastLane) && ( + + · Fast Lane via sixflags.com + + )} + ) : undefined}> Rides {liveRides ? ( diff --git a/backend/src/routes/rides.ts b/backend/src/routes/rides.ts index c55ed32..8afccfc 100644 --- a/backend/src/routes/rides.ts +++ b/backend/src/routes/rides.ts @@ -5,11 +5,14 @@ import { getCoasterSet } from "../../../lib/coaster-data"; import { getTodayLocal, isWithinOperatingWindow } from "../../../lib/env"; 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 type { LiveRidesResult } from "../../../lib/scrapers/queuetimes"; +import type { FastLaneResult } from "../../../lib/scrapers/sixflags-waittimes"; const liveRidesCache = new TtlCache(5 * 60 * 1000); +const fastLaneCache = new TtlCache(5 * 60 * 1000); const app = new Hono(); @@ -41,6 +44,30 @@ app.get("/:id/rides", async (c) => { 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 = diff --git a/components/LiveRidePanel.tsx b/components/LiveRidePanel.tsx index 4b780bd..8c709c3 100644 --- a/components/LiveRidePanel.tsx +++ b/components/LiveRidePanel.tsx @@ -12,7 +12,9 @@ interface LiveRidePanelProps { export function LiveRidePanel({ liveRides, parkOpenToday, isWeatherDelay }: LiveRidePanelProps) { const { rides } = liveRides; const hasCoasters = rides.some((r) => r.isCoaster); + const hasFastLane = rides.some((r) => r.hasFastLane); const [coastersOnly, setCoastersOnly] = useState(false); + const [fastLaneMode, setFastLaneMode] = useState(false); // Pre-select coaster filter if Coaster Mode is enabled on the homepage. useEffect(() => { @@ -21,6 +23,21 @@ export function LiveRidePanel({ liveRides, parkOpenToday, isWeatherDelay }: Live } }, [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 openRides = visible.filter((r) => r.isOpen); const closedRides = visible.filter((r) => !r.isOpen); @@ -94,35 +111,69 @@ export function LiveRidePanel({ liveRides, parkOpenToday, isWeatherDelay }: Live )} - {/* Coaster toggle — only shown when the park has categorised coasters */} - {hasCoasters && ( - + {/* Toggle group — pushed to the right */} + {(hasCoasters || hasFastLane) && ( +
+ {/* Fast Lane toggle — swaps shown waits to Fast Lane numbers */} + {hasFastLane && ( + + )} + + {/* Coaster toggle — only shown when the park has categorised coasters */} + {hasCoasters && ( + + )} +
)} @@ -132,16 +183,18 @@ export function LiveRidePanel({ liveRides, parkOpenToday, isWeatherDelay }: Live gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))", gap: 6, }}> - {openRides.map((ride) => )} - {closedRides.map((ride) => )} + {openRides.map((ride) => )} + {closedRides.map((ride) => )} ); } -function RideRow({ ride }: { ride: LiveRide }) { +function RideRow({ ride, fastLaneMode }: { ride: LiveRide; fastLaneMode: boolean }) { const showWait = ride.isOpen && ride.waitMinutes > 0; + const fastLaneActive = fastLaneMode && ride.hasFastLane; + const flWait = ride.fastLaneMinutes ?? 0; return (
- {showWait && ( - - {ride.waitMinutes} min - - )} - {ride.isOpen && !showWait && ( - - walk-on - + {/* Fast Lane mode: swap the shown wait to the Fast Lane number */} + {fastLaneMode ? ( + ride.isOpen && fastLaneActive ? ( + + {flWait > 0 ? `⚡ ${flWait} min` : "⚡ walk-on"} + + ) : ride.isOpen ? ( + + no Fast Lane + + ) : null + ) : ( + <> + {showWait && ( + + {ride.waitMinutes} min + + )} + {ride.isOpen && !showWait && ( + + walk-on + + )} + )} ); diff --git a/lib/scrapers/queuetimes.ts b/lib/scrapers/queuetimes.ts index 82829c0..cd93af6 100644 --- a/lib/scrapers/queuetimes.ts +++ b/lib/scrapers/queuetimes.ts @@ -25,6 +25,11 @@ export interface LiveRide { lastUpdated: string; // ISO 8601 /** True when the ride name appears in the RCDB coaster list for this park. */ 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 { diff --git a/lib/scrapers/sixflags-waittimes.ts b/lib/scrapers/sixflags-waittimes.ts new file mode 100644 index 0000000..ed9b24b --- /dev/null +++ b/lib/scrapers/sixflags-waittimes.ts @@ -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 { + 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 }; +} diff --git a/tests/fast-lane-matching.test.ts b/tests/fast-lane-matching.test.ts new file mode 100644 index 0000000..6b71a5c --- /dev/null +++ b/tests/fast-lane-matching.test.ts @@ -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 { + 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[]) { + 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, + ); +});