feat: add Fast Lane wait times toggle on park pages
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:
2026-05-29 22:51:52 -04:00
parent aa46cc1b3d
commit bfe099322f
6 changed files with 455 additions and 70 deletions
+24 -17
View File
@@ -97,23 +97,30 @@ export default async function ParkPage({ params, searchParams }: PageProps) {
{/* ── Ride Status ─────────────────────────────────────────────────── */} {/* ── Ride Status ─────────────────────────────────────────────────── */}
<section> <section>
<SectionHeading aside={liveRides ? ( <SectionHeading aside={liveRides ? (
<a <span style={{ display: "flex", alignItems: "center", gap: 6, flexWrap: "wrap", justifyContent: "flex-end" }}>
href="https://queue-times.com" <a
target="_blank" href="https://queue-times.com"
rel="noopener noreferrer" target="_blank"
style={{ rel="noopener noreferrer"
fontSize: "0.68rem", style={{
color: "var(--color-text-dim)", fontSize: "0.68rem",
textDecoration: "none", color: "var(--color-text-dim)",
display: "flex", textDecoration: "none",
alignItems: "center", display: "flex",
gap: 4, alignItems: "center",
transition: "color 120ms ease", gap: 4,
}} transition: "color 120ms ease",
className="park-name-link" }}
> className="park-name-link"
via queue-times.com >
</a> via queue-times.com
</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 ? (
+27
View File
@@ -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 =
+134 -53
View File
@@ -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,35 +111,69 @@ export function LiveRidePanel({ liveRides, parkOpenToday, isWeatherDelay }: Live
</div> </div>
)} )}
{/* Coaster toggle — only shown when the park has categorised coasters */} {/* Toggle group — pushed to the right */}
{hasCoasters && ( {(hasCoasters || hasFastLane) && (
<button <div style={{ marginLeft: "auto", display: "flex", gap: 8, flexWrap: "wrap" }}>
onClick={() => setCoastersOnly((v) => !v)} {/* Fast Lane toggle — swaps shown waits to Fast Lane numbers */}
style={{ {hasFastLane && (
marginLeft: "auto", <button
display: "flex", onClick={toggleFastLane}
alignItems: "center", style={{
gap: 5, display: "flex",
padding: "4px 12px", alignItems: "center",
borderRadius: 20, gap: 5,
border: coastersOnly padding: "4px 12px",
? "1px solid var(--color-accent)" borderRadius: 20,
: "1px solid var(--color-border)", border: fastLaneMode
background: coastersOnly ? "1px solid var(--color-accent)"
? "var(--color-accent-muted)" : "1px solid var(--color-border)",
: "var(--color-surface)", background: fastLaneMode
color: coastersOnly ? "var(--color-accent-muted)"
? "var(--color-accent)" : "var(--color-surface)",
: "var(--color-text-muted)", color: fastLaneMode
fontSize: "0.72rem", ? "var(--color-accent)"
fontWeight: 600, : "var(--color-text-muted)",
cursor: "pointer", fontSize: "0.72rem",
transition: "background 150ms ease, border-color 150ms ease, color 150ms ease", fontWeight: 600,
whiteSpace: "nowrap", cursor: "pointer",
}} transition: "background 150ms ease, border-color 150ms ease, color 150ms ease",
> whiteSpace: "nowrap",
🎢 Coasters only }}
</button> >
Fast Lane
</button>
)}
{/* Coaster toggle — only shown when the park has categorised coasters */}
{hasCoasters && (
<button
onClick={() => setCoastersOnly((v) => !v)}
style={{
display: "flex",
alignItems: "center",
gap: 5,
padding: "4px 12px",
borderRadius: 20,
border: coastersOnly
? "1px solid var(--color-accent)"
: "1px solid var(--color-border)",
background: coastersOnly
? "var(--color-accent-muted)"
: "var(--color-surface)",
color: coastersOnly
? "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",
}}
>
🎢 Coasters only
</button>
)}
</div>
)} )}
</div> </div>
@@ -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,27 +227,55 @@ function RideRow({ ride }: { ride: LiveRide }) {
{ride.name} {ride.name}
</span> </span>
</div> </div>
{showWait && ( {/* Fast Lane mode: swap the shown wait to the Fast Lane number */}
<span style={{ {fastLaneMode ? (
fontSize: "0.72rem", ride.isOpen && fastLaneActive ? (
color: "var(--color-open-hours)", <span style={{
fontWeight: 600, fontSize: "0.72rem",
flexShrink: 0, color: "var(--color-accent)",
whiteSpace: "nowrap", fontWeight: 600,
}}> flexShrink: 0,
{ride.waitMinutes} min whiteSpace: "nowrap",
</span> }}>
)} {flWait > 0 ? `${flWait} min` : "⚡ walk-on"}
{ride.isOpen && !showWait && ( </span>
<span style={{ ) : ride.isOpen ? (
fontSize: "0.68rem", <span style={{
color: "var(--color-open-text)", fontSize: "0.68rem",
fontWeight: 500, color: "var(--color-text-muted)",
flexShrink: 0, fontWeight: 500,
opacity: 0.7, flexShrink: 0,
}}> opacity: 0.7,
walk-on whiteSpace: "nowrap",
</span> }}>
no Fast Lane
</span>
) : null
) : (
<>
{showWait && (
<span style={{
fontSize: "0.72rem",
color: "var(--color-open-hours)",
fontWeight: 600,
flexShrink: 0,
whiteSpace: "nowrap",
}}>
{ride.waitMinutes} min
</span>
)}
{ride.isOpen && !showWait && (
<span style={{
fontSize: "0.68rem",
color: "var(--color-open-text)",
fontWeight: 500,
flexShrink: 0,
opacity: 0.7,
}}>
walk-on
</span>
)}
</>
)} )}
</div> </div>
); );
+5
View File
@@ -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 {
+163
View File
@@ -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 };
}
+102
View File
@@ -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,
);
});