feat: use dateless Six Flags API endpoint for live today data
All checks were successful
Build and Deploy / Build & Push (push) Successful in 17s
All checks were successful
Build and Deploy / Build & Push (push) Successful in 17s
The API without a date param returns today's operating data directly,
invalidating the previous assumption that today's date was always missing.
- Add fetchToday(apiId, revalidate?) to sixflags.ts — calls the dateless
endpoint with optional ISR cache
- Extract parseApiDay() helper shared by scrapeMonth and fetchToday
- Update upsertDay WHERE clause: >= date('now') so today can be updated
(was > date('now'), which froze today after first write)
- scrape.ts: add a today-scrape pass after the monthly loop so each run
always writes fresh today data to the DB
- app/page.tsx: fetch live today data for all parks (5-min ISR) and merge
into the data map before computing open/closing/weatherDelay status
- app/park/[id]/page.tsx: prefer live today data from API for todayData
so weather delays and hour changes surface within 5 minutes
- scrapeRidesForDay: update comment only — role unchanged (QT fallback)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
29
app/page.tsx
29
app/page.tsx
@@ -1,10 +1,12 @@
|
|||||||
import { HomePageClient } from "@/components/HomePageClient";
|
import { HomePageClient } from "@/components/HomePageClient";
|
||||||
import { PARKS } from "@/lib/parks";
|
import { PARKS } from "@/lib/parks";
|
||||||
import { openDb, getDateRange } from "@/lib/db";
|
import { openDb, getDateRange, getApiId } from "@/lib/db";
|
||||||
import { getTodayLocal, isWithinOperatingWindow, getOperatingStatus } from "@/lib/env";
|
import { getTodayLocal, isWithinOperatingWindow, getOperatingStatus } from "@/lib/env";
|
||||||
import { fetchLiveRides } from "@/lib/scrapers/queuetimes";
|
import { fetchLiveRides } from "@/lib/scrapers/queuetimes";
|
||||||
|
import { fetchToday } from "@/lib/scrapers/sixflags";
|
||||||
import { QUEUE_TIMES_IDS } from "@/lib/queue-times-map";
|
import { QUEUE_TIMES_IDS } from "@/lib/queue-times-map";
|
||||||
import { readParkMeta, getCoasterSet } from "@/lib/park-meta";
|
import { readParkMeta, getCoasterSet } from "@/lib/park-meta";
|
||||||
|
import type { DayData } from "@/lib/db";
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
searchParams: Promise<{ week?: string }>;
|
searchParams: Promise<{ week?: string }>;
|
||||||
@@ -49,6 +51,31 @@ export default async function HomePage({ searchParams }: PageProps) {
|
|||||||
|
|
||||||
const db = openDb();
|
const db = openDb();
|
||||||
const data = getDateRange(db, weekStart, endDate);
|
const data = getDateRange(db, weekStart, endDate);
|
||||||
|
|
||||||
|
// Merge live today data from the Six Flags API (dateless endpoint, 5-min ISR cache).
|
||||||
|
// This ensures weather delays, early closures, and hour changes surface within 5 minutes
|
||||||
|
// without waiting for the next scheduled scrape. Only fetched when viewing the current week.
|
||||||
|
if (weekDates.includes(today)) {
|
||||||
|
const todayResults = await Promise.all(
|
||||||
|
PARKS.map(async (p) => {
|
||||||
|
const apiId = getApiId(db, p.id);
|
||||||
|
if (!apiId) return null;
|
||||||
|
const live = await fetchToday(apiId, 300); // 5-min ISR cache
|
||||||
|
return live ? { parkId: p.id, live } : null;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
for (const result of todayResults) {
|
||||||
|
if (!result) continue;
|
||||||
|
const { parkId, live } = result;
|
||||||
|
if (!data[parkId]) data[parkId] = {};
|
||||||
|
data[parkId][today] = {
|
||||||
|
isOpen: live.isOpen,
|
||||||
|
hoursLabel: live.hoursLabel ?? null,
|
||||||
|
specialType: live.specialType ?? null,
|
||||||
|
} satisfies DayData;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
db.close();
|
db.close();
|
||||||
|
|
||||||
const scrapedCount = Object.values(data).reduce(
|
const scrapedCount = Object.values(data).reduce(
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { PARK_MAP } from "@/lib/parks";
|
|||||||
import { openDb, getParkMonthData, getApiId } from "@/lib/db";
|
import { openDb, getParkMonthData, getApiId } from "@/lib/db";
|
||||||
import { scrapeRidesForDay } from "@/lib/scrapers/sixflags";
|
import { scrapeRidesForDay } from "@/lib/scrapers/sixflags";
|
||||||
import { fetchLiveRides } from "@/lib/scrapers/queuetimes";
|
import { fetchLiveRides } from "@/lib/scrapers/queuetimes";
|
||||||
|
import { fetchToday } from "@/lib/scrapers/sixflags";
|
||||||
import { QUEUE_TIMES_IDS } from "@/lib/queue-times-map";
|
import { QUEUE_TIMES_IDS } from "@/lib/queue-times-map";
|
||||||
import { readParkMeta, getCoasterSet } from "@/lib/park-meta";
|
import { readParkMeta, getCoasterSet } from "@/lib/park-meta";
|
||||||
import { ParkMonthCalendar } from "@/components/ParkMonthCalendar";
|
import { ParkMonthCalendar } from "@/components/ParkMonthCalendar";
|
||||||
@@ -44,7 +45,13 @@ export default async function ParkPage({ params, searchParams }: PageProps) {
|
|||||||
const apiId = getApiId(db, id);
|
const apiId = getApiId(db, id);
|
||||||
db.close();
|
db.close();
|
||||||
|
|
||||||
const todayData = monthData[today];
|
// Prefer live today data from the Six Flags API (5-min ISR cache) so that
|
||||||
|
// weather delays and hour changes surface immediately rather than showing
|
||||||
|
// stale DB values. Fall back to DB if the API call fails.
|
||||||
|
const liveToday = apiId !== null ? await fetchToday(apiId, 300).catch(() => null) : null;
|
||||||
|
const todayData = liveToday
|
||||||
|
? { isOpen: liveToday.isOpen, hoursLabel: liveToday.hoursLabel ?? null, specialType: liveToday.specialType ?? null }
|
||||||
|
: monthData[today];
|
||||||
const parkOpenToday = todayData?.isOpen && todayData?.hoursLabel;
|
const parkOpenToday = todayData?.isOpen && todayData?.hoursLabel;
|
||||||
|
|
||||||
// ── Ride data: try live Queue-Times first, fall back to schedule ──────────
|
// ── Ride data: try live Queue-Times first, fall back to schedule ──────────
|
||||||
@@ -80,10 +87,8 @@ export default async function ParkPage({ params, searchParams }: PageProps) {
|
|||||||
liveRides.rides.length > 0 &&
|
liveRides.rides.length > 0 &&
|
||||||
liveRides.rides.every((r) => !r.isOpen);
|
liveRides.rides.every((r) => !r.isOpen);
|
||||||
|
|
||||||
// Only hit the schedule API as a fallback when live data is unavailable
|
// Only hit the schedule API as a fallback when Queue-Times live data is unavailable.
|
||||||
if (!liveRides && apiId !== null) {
|
if (!liveRides && apiId !== null) {
|
||||||
// Note: the API drops today's date from its response (only returns future dates),
|
|
||||||
// so scrapeRidesForDay may fall back to the nearest upcoming date.
|
|
||||||
ridesResult = await scrapeRidesForDay(apiId, today);
|
ridesResult = await scrapeRidesForDay(apiId, today);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
10
lib/db.ts
10
lib/db.ts
@@ -46,12 +46,10 @@ export function upsertDay(
|
|||||||
hoursLabel?: string,
|
hoursLabel?: string,
|
||||||
specialType?: string
|
specialType?: string
|
||||||
) {
|
) {
|
||||||
// Today and past dates: INSERT new rows freely, but NEVER overwrite existing records.
|
// Today and future dates: full upsert — hours can change (e.g. weather delays,
|
||||||
// Once an operating day begins the API drops that date from its response, so a
|
// early closures) and the dateless API endpoint now returns today's live data.
|
||||||
// re-scrape would incorrectly record the day as closed. The DB row written when
|
|
||||||
// the date was still in the future is the permanent truth for that day.
|
|
||||||
//
|
//
|
||||||
// Future dates only: full upsert — hours can change and closures can be added.
|
// Past dates: INSERT-only — never overwrite once the day has passed.
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
INSERT INTO park_days (park_id, date, is_open, hours_label, special_type, scraped_at)
|
INSERT INTO park_days (park_id, date, is_open, hours_label, special_type, scraped_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
@@ -60,7 +58,7 @@ export function upsertDay(
|
|||||||
hours_label = excluded.hours_label,
|
hours_label = excluded.hours_label,
|
||||||
special_type = excluded.special_type,
|
special_type = excluded.special_type,
|
||||||
scraped_at = excluded.scraped_at
|
scraped_at = excluded.scraped_at
|
||||||
WHERE park_days.date > date('now')
|
WHERE park_days.date >= date('now')
|
||||||
`).run(parkId, date, isOpen ? 1 : 0, hoursLabel ?? null, specialType ?? null, new Date().toISOString());
|
`).run(parkId, date, isOpen ? 1 : 0, hoursLabel ?? null, specialType ?? null, new Date().toISOString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -166,13 +166,48 @@ function apiDateToIso(apiDate: string): string {
|
|||||||
return `${yyyy}-${mm}-${dd}`;
|
return `${yyyy}-${mm}-${dd}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Parse a single ApiDay into a DayResult. Shared by scrapeMonth and fetchToday. */
|
||||||
|
function parseApiDay(d: ApiDay): DayResult {
|
||||||
|
const date = parseApiDate(d.date);
|
||||||
|
const operating =
|
||||||
|
d.operatings?.find((o) => o.operatingTypeName === "Park") ??
|
||||||
|
d.operatings?.[0];
|
||||||
|
const item = operating?.items?.[0];
|
||||||
|
const hoursLabel =
|
||||||
|
item?.timeFrom && item?.timeTo
|
||||||
|
? `${fmt24(item.timeFrom)} – ${fmt24(item.timeTo)}`
|
||||||
|
: undefined;
|
||||||
|
const isPassholderPreview = d.events?.some((e) =>
|
||||||
|
e.extEventName.toLowerCase().includes("passholder preview")
|
||||||
|
) ?? false;
|
||||||
|
const isBuyout = item?.isBuyout ?? false;
|
||||||
|
const isOpen = !d.isParkClosed && hoursLabel !== undefined && (!isBuyout || isPassholderPreview);
|
||||||
|
const specialType: DayResult["specialType"] = isPassholderPreview ? "passholder_preview" : undefined;
|
||||||
|
return { date, isOpen, hoursLabel: isOpen ? hoursLabel : undefined, specialType };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch ride operating status for a given date.
|
* Fetch today's operating data directly (no date param = API returns today).
|
||||||
|
* Pass `revalidate` (seconds) for Next.js ISR caching; omit for a fully fresh fetch.
|
||||||
|
*/
|
||||||
|
export async function fetchToday(apiId: number, revalidate?: number): Promise<DayResult | null> {
|
||||||
|
try {
|
||||||
|
const url = `${API_BASE}/${apiId}`;
|
||||||
|
const raw = await fetchApi(url, 0, 0, revalidate);
|
||||||
|
if (!raw.dates.length) return null;
|
||||||
|
return parseApiDay(raw.dates[0]);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch ride operating status for a given date. Used as a fallback when
|
||||||
|
* Queue-Times live data is unavailable.
|
||||||
*
|
*
|
||||||
* The Six Flags API drops dates that have already started (including today),
|
* The monthly API endpoint (`?date=YYYYMM`) may not include today; use
|
||||||
* returning only tomorrow onwards. When the requested date is missing, we fall
|
* `fetchToday(apiId)` to get today's park hours directly. The fallback
|
||||||
* back to the nearest available upcoming date in the same month's response so
|
* chain here will find the nearest upcoming date if an exact match is missing.
|
||||||
* the UI can still show a useful (if approximate) schedule.
|
|
||||||
*
|
*
|
||||||
* Returns null if no ride data could be found at all (API error, pre-season,
|
* Returns null if no ride data could be found at all (API error, pre-season,
|
||||||
* no venues in response).
|
* no venues in response).
|
||||||
@@ -286,30 +321,7 @@ export async function scrapeMonth(
|
|||||||
|
|
||||||
const data = await fetchApi(url);
|
const data = await fetchApi(url);
|
||||||
|
|
||||||
return data.dates.map((d): DayResult => {
|
return data.dates.map(parseApiDay);
|
||||||
const date = parseApiDate(d.date);
|
|
||||||
// Prefer the "Park" operating entry; fall back to first entry
|
|
||||||
const operating =
|
|
||||||
d.operatings?.find((o) => o.operatingTypeName === "Park") ??
|
|
||||||
d.operatings?.[0];
|
|
||||||
const item = operating?.items?.[0];
|
|
||||||
const hoursLabel =
|
|
||||||
item?.timeFrom && item?.timeTo
|
|
||||||
? `${fmt24(item.timeFrom)} – ${fmt24(item.timeTo)}`
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const isPassholderPreview = d.events?.some((e) =>
|
|
||||||
e.extEventName.toLowerCase().includes("passholder preview")
|
|
||||||
) ?? false;
|
|
||||||
|
|
||||||
const isBuyout = item?.isBuyout ?? false;
|
|
||||||
|
|
||||||
// Buyout days are private events — treat as closed unless it's a passholder preview
|
|
||||||
const isOpen = !d.isParkClosed && hoursLabel !== undefined && (!isBuyout || isPassholderPreview);
|
|
||||||
const specialType: DayResult["specialType"] = isPassholderPreview ? "passholder_preview" : undefined;
|
|
||||||
|
|
||||||
return { date, isOpen, hoursLabel: isOpen ? hoursLabel : undefined, specialType };
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
import { openDb, upsertDay, getApiId, isMonthScraped } from "../lib/db";
|
import { openDb, upsertDay, getApiId, isMonthScraped } from "../lib/db";
|
||||||
import { PARKS } from "../lib/parks";
|
import { PARKS } from "../lib/parks";
|
||||||
import { scrapeMonth, RateLimitError } from "../lib/scrapers/sixflags";
|
import { scrapeMonth, fetchToday, RateLimitError } from "../lib/scrapers/sixflags";
|
||||||
import { readParkMeta, writeParkMeta, areCoastersStale } from "../lib/park-meta";
|
import { readParkMeta, writeParkMeta, areCoastersStale } from "../lib/park-meta";
|
||||||
import { scrapeRcdbCoasters } from "../lib/scrapers/rcdb";
|
import { scrapeRcdbCoasters } from "../lib/scrapers/rcdb";
|
||||||
|
|
||||||
@@ -100,6 +100,25 @@ async function main() {
|
|||||||
console.log(`\n ${totalFetched} fetched ${totalSkipped} skipped ${totalErrors} errors`);
|
console.log(`\n ${totalFetched} fetched ${totalSkipped} skipped ${totalErrors} errors`);
|
||||||
if (totalErrors > 0) console.log(" Re-run to retry failed months.");
|
if (totalErrors > 0) console.log(" Re-run to retry failed months.");
|
||||||
|
|
||||||
|
// ── Today scrape (always fresh — dateless endpoint returns current day) ────
|
||||||
|
console.log("\n── Today's data ──");
|
||||||
|
for (const park of ready) {
|
||||||
|
const apiId = getApiId(db, park.id)!;
|
||||||
|
process.stdout.write(` ${park.shortName.padEnd(22)} `);
|
||||||
|
try {
|
||||||
|
const today = await fetchToday(apiId);
|
||||||
|
if (today) {
|
||||||
|
upsertDay(db, park.id, today.date, today.isOpen, today.hoursLabel, today.specialType);
|
||||||
|
console.log(today.isOpen ? `open ${today.hoursLabel ?? ""}` : "closed");
|
||||||
|
} else {
|
||||||
|
console.log("no data");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
console.log("error");
|
||||||
|
}
|
||||||
|
await sleep(500);
|
||||||
|
}
|
||||||
|
|
||||||
db.close();
|
db.close();
|
||||||
|
|
||||||
// ── RCDB coaster scrape (30-day staleness) ────────────────────────────────
|
// ── RCDB coaster scrape (30-day staleness) ────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user