Files
SixFlagsSuperCalendar/lib/scrapers/sixflags-waittimes.ts
T
josh e888261ed9
Build and Deploy / Lint, typecheck, test (push) Successful in 35s
Build and Deploy / Build & Push (push) Successful in 1m8s
feat: prefer Six Flags regular waits, show Fast Lane at 0, mark outages on chart
Three usability fixes after a day of using the ride detail page.

1. Six Flags is now the primary source for regular wait times. SF's
   /wait-times endpoint reports regular waits alongside Fast Lane, and it
   updates more promptly than Queue-Times around park-open. The sampler
   and the live /rides + ride-history routes all prefer SF's regularWaittime
   when its createdDateTime is non-empty; Queue-Times remains the fallback
   and the authoritative isOpen source.

2. The today chart's Fast Lane line now stays visible when its value is 0
   (walk-on). Y-axis bottom padding ensures the line sits clearly above the
   X-axis frame instead of being clipped against it. The tooltip shows
   "walk-on" instead of "0 min" for that case.

3. Outages are now explicit on the chart instead of just being gaps.
   computeOutages walks today's samples to find contiguous closed runs and
   numbers them chronologically. Each outage renders as a translucent pink
   ReferenceArea with a "#N" label. The custom tooltip detects when the
   cursor is over an outage span and shows "Outage #N — Hh Mm" (e.g.
   "Outage #2 — 1h 28m") in place of the wait/Fast Lane rows.

Includes a seed-test-samples.ts dev script for eyeballing the chart with
synthetic outage data.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 18:54:02 -04:00

175 lines
5.2 KiB
TypeScript

/**
* 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 regular wait in minutes from Six Flags; null when the endpoint has no data. */
regularMinutes: number | null;
/** 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),
regularMinutes: d.regularWaittime?.createdDateTime
? d.regularWaittime.waitTime
: null,
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 Six Flags wait-times row 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 both the regular and Fast Lane wait when a match exists, or null
* when no ride matches. (Function name is historical — it originally only
* exposed Fast Lane data.)
*/
export function lookupFastLane(
rideName: string,
result: FastLaneResult,
): { hasFastLane: boolean; fastLaneMinutes: number | null; regularMinutes: 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,
regularMinutes: match.regularMinutes,
};
}