nav(-1)}
aria-label="Previous week"
- style={btnStyle}
+ style={navBtnStyle}
+ onMouseOver={(e) => Object.assign((e.target as HTMLElement).style, navBtnHover)}
+ onMouseOut={(e) => Object.assign((e.target as HTMLElement).style, navBtnStyle)}
>
←
+ {!isCurrentWeek && (
+
router.push("/")}
+ aria-label="Jump to current week"
+ style={todayBtnStyle}
+ onMouseOver={(e) => Object.assign((e.target as HTMLElement).style, todayBtnHover)}
+ onMouseOut={(e) => Object.assign((e.target as HTMLElement).style, todayBtnStyle)}
+ >
+ Today
+
+ )}
+
{formatLabel(weekDates)}
@@ -57,7 +74,9 @@ export function WeekNav({ weekStart, weekDates }: WeekNavProps) {
nav(1)}
aria-label="Next week"
- style={btnStyle}
+ style={navBtnStyle}
+ onMouseOver={(e) => Object.assign((e.target as HTMLElement).style, navBtnHover)}
+ onMouseOut={(e) => Object.assign((e.target as HTMLElement).style, navBtnStyle)}
>
→
@@ -65,7 +84,7 @@ export function WeekNav({ weekStart, weekDates }: WeekNavProps) {
);
}
-const btnStyle: React.CSSProperties = {
+const navBtnStyle: React.CSSProperties = {
padding: "6px 14px",
borderRadius: 6,
border: "1px solid var(--color-border)",
@@ -74,4 +93,47 @@ const btnStyle: React.CSSProperties = {
cursor: "pointer",
fontSize: "1rem",
lineHeight: 1,
+ transition: "background 150ms ease, border-color 150ms ease, color 150ms ease",
+};
+
+const navBtnHover: React.CSSProperties = {
+ padding: "6px 14px",
+ borderRadius: 6,
+ border: "1px solid var(--color-text-dim)",
+ background: "var(--color-surface-2)",
+ color: "var(--color-text-secondary)",
+ cursor: "pointer",
+ fontSize: "1rem",
+ lineHeight: 1,
+ transition: "background 150ms ease, border-color 150ms ease, color 150ms ease",
+};
+
+const todayBtnStyle: React.CSSProperties = {
+ padding: "5px 12px",
+ borderRadius: 6,
+ border: "1px solid var(--color-accent-muted)",
+ background: "transparent",
+ color: "var(--color-accent)",
+ cursor: "pointer",
+ fontSize: "0.75rem",
+ fontWeight: 600,
+ letterSpacing: "0.04em",
+ textTransform: "uppercase",
+ lineHeight: 1,
+ transition: "background 150ms ease, color 150ms ease",
+};
+
+const todayBtnHover: React.CSSProperties = {
+ padding: "5px 12px",
+ borderRadius: 6,
+ border: "1px solid var(--color-accent-muted)",
+ background: "var(--color-accent-muted)",
+ color: "var(--color-accent-text)",
+ cursor: "pointer",
+ fontSize: "0.75rem",
+ fontWeight: 600,
+ letterSpacing: "0.04em",
+ textTransform: "uppercase",
+ lineHeight: 1,
+ transition: "background 150ms ease, color 150ms ease",
};
diff --git a/lib/db.ts b/lib/db.ts
index c9f82f2..09ef79a 100644
--- a/lib/db.ts
+++ b/lib/db.ts
@@ -99,6 +99,42 @@ export function getDateRange(
return result;
}
+/**
+ * Returns scraped DayData for a single park for an entire month.
+ * Shape: { 'YYYY-MM-DD': DayData }
+ */
+export function getParkMonthData(
+ db: Database.Database,
+ parkId: string,
+ year: number,
+ month: number,
+): Record
{
+ const prefix = `${year}-${String(month).padStart(2, "0")}`;
+ const rows = db
+ .prepare(
+ `SELECT date, is_open, hours_label, special_type
+ FROM park_days
+ WHERE park_id = ? AND date LIKE ? || '-%'
+ ORDER BY date`
+ )
+ .all(parkId, prefix) as {
+ date: string;
+ is_open: number;
+ hours_label: string | null;
+ special_type: string | null;
+ }[];
+
+ const result: Record = {};
+ for (const row of rows) {
+ result[row.date] = {
+ isOpen: row.is_open === 1,
+ hoursLabel: row.hours_label,
+ specialType: row.special_type,
+ };
+ }
+ return result;
+}
+
/** Returns a map of parkId → boolean[] (index 0 = day 1) for a given month. */
export function getMonthCalendar(
db: Database.Database,
diff --git a/lib/parks.ts b/lib/parks.ts
index c852f5b..6cd358d 100644
--- a/lib/parks.ts
+++ b/lib/parks.ts
@@ -15,6 +15,7 @@ export const PARKS: Park[] = [
shortName: "Great Adventure",
chain: "sixflags",
slug: "greatadventure",
+ region: "Northeast",
location: { lat: 40.1376, lng: -74.4388, city: "Jackson", state: "NJ" },
timezone: "America/New_York",
website: "https://www.sixflags.com",
@@ -25,6 +26,7 @@ export const PARKS: Park[] = [
shortName: "Magic Mountain",
chain: "sixflags",
slug: "magicmountain",
+ region: "West & International",
location: { lat: 34.4252, lng: -118.5973, city: "Valencia", state: "CA" },
timezone: "America/Los_Angeles",
website: "https://www.sixflags.com",
@@ -35,6 +37,7 @@ export const PARKS: Park[] = [
shortName: "Great America",
chain: "sixflags",
slug: "greatamerica",
+ region: "Midwest",
location: { lat: 42.3702, lng: -87.9358, city: "Gurnee", state: "IL" },
timezone: "America/Chicago",
website: "https://www.sixflags.com",
@@ -45,6 +48,7 @@ export const PARKS: Park[] = [
shortName: "Over Georgia",
chain: "sixflags",
slug: "overgeorgia",
+ region: "Southeast",
location: { lat: 33.7718, lng: -84.5494, city: "Austell", state: "GA" },
timezone: "America/New_York",
website: "https://www.sixflags.com",
@@ -55,6 +59,7 @@ export const PARKS: Park[] = [
shortName: "Over Texas",
chain: "sixflags",
slug: "overtexas",
+ region: "Texas & South",
location: { lat: 32.7554, lng: -97.0639, city: "Arlington", state: "TX" },
timezone: "America/Chicago",
website: "https://www.sixflags.com",
@@ -65,6 +70,7 @@ export const PARKS: Park[] = [
shortName: "St. Louis",
chain: "sixflags",
slug: "stlouis",
+ region: "Midwest",
location: { lat: 38.5153, lng: -90.6751, city: "Eureka", state: "MO" },
timezone: "America/Chicago",
website: "https://www.sixflags.com",
@@ -75,6 +81,7 @@ export const PARKS: Park[] = [
shortName: "Fiesta Texas",
chain: "sixflags",
slug: "fiestatexas",
+ region: "Texas & South",
location: { lat: 29.6054, lng: -98.622, city: "San Antonio", state: "TX" },
timezone: "America/Chicago",
website: "https://www.sixflags.com",
@@ -85,6 +92,7 @@ export const PARKS: Park[] = [
shortName: "New England",
chain: "sixflags",
slug: "newengland",
+ region: "Northeast",
location: { lat: 42.037, lng: -72.6151, city: "Agawam", state: "MA" },
timezone: "America/New_York",
website: "https://www.sixflags.com",
@@ -95,6 +103,7 @@ export const PARKS: Park[] = [
shortName: "Discovery Kingdom",
chain: "sixflags",
slug: "discoverykingdom",
+ region: "West & International",
location: { lat: 38.136, lng: -122.2314, city: "Vallejo", state: "CA" },
timezone: "America/Los_Angeles",
website: "https://www.sixflags.com",
@@ -105,6 +114,7 @@ export const PARKS: Park[] = [
shortName: "Mexico",
chain: "sixflags",
slug: "mexico",
+ region: "West & International",
location: { lat: 19.2982, lng: -99.2146, city: "Mexico City", state: "Mexico" },
timezone: "America/Mexico_City",
website: "https://www.sixflags.com",
@@ -115,6 +125,7 @@ export const PARKS: Park[] = [
shortName: "Great Escape",
chain: "sixflags",
slug: "greatescape",
+ region: "Northeast",
location: { lat: 43.3537, lng: -73.6776, city: "Queensbury", state: "NY" },
timezone: "America/New_York",
website: "https://www.sixflags.com",
@@ -125,6 +136,7 @@ export const PARKS: Park[] = [
shortName: "Darien Lake",
chain: "sixflags",
slug: "darienlake",
+ region: "Northeast",
location: { lat: 42.9915, lng: -78.3895, city: "Darien Center", state: "NY" },
timezone: "America/New_York",
website: "https://www.sixflags.com",
@@ -136,6 +148,7 @@ export const PARKS: Park[] = [
shortName: "Cedar Point",
chain: "sixflags",
slug: "cedarpoint",
+ region: "Midwest",
location: { lat: 41.4784, lng: -82.6832, city: "Sandusky", state: "OH" },
timezone: "America/New_York",
website: "https://www.sixflags.com",
@@ -146,6 +159,7 @@ export const PARKS: Park[] = [
shortName: "Knott's",
chain: "sixflags",
slug: "knotts",
+ region: "West & International",
location: { lat: 33.8442, lng: -117.9989, city: "Buena Park", state: "CA" },
timezone: "America/Los_Angeles",
website: "https://www.sixflags.com",
@@ -156,6 +170,7 @@ export const PARKS: Park[] = [
shortName: "Canada's Wonderland",
chain: "sixflags",
slug: "canadaswonderland",
+ region: "Northeast",
location: { lat: 43.8426, lng: -79.5396, city: "Vaughan", state: "ON" },
timezone: "America/Toronto",
website: "https://www.sixflags.com",
@@ -166,6 +181,7 @@ export const PARKS: Park[] = [
shortName: "Carowinds",
chain: "sixflags",
slug: "carowinds",
+ region: "Southeast",
location: { lat: 35.1043, lng: -80.9394, city: "Charlotte", state: "NC" },
timezone: "America/New_York",
website: "https://www.sixflags.com",
@@ -176,6 +192,7 @@ export const PARKS: Park[] = [
shortName: "Kings Dominion",
chain: "sixflags",
slug: "kingsdominion",
+ region: "Southeast",
location: { lat: 37.8357, lng: -77.4463, city: "Doswell", state: "VA" },
timezone: "America/New_York",
website: "https://www.sixflags.com",
@@ -186,6 +203,7 @@ export const PARKS: Park[] = [
shortName: "Kings Island",
chain: "sixflags",
slug: "kingsisland",
+ region: "Midwest",
location: { lat: 39.3442, lng: -84.2696, city: "Mason", state: "OH" },
timezone: "America/New_York",
website: "https://www.sixflags.com",
@@ -196,6 +214,7 @@ export const PARKS: Park[] = [
shortName: "Valleyfair",
chain: "sixflags",
slug: "valleyfair",
+ region: "Midwest",
location: { lat: 44.7227, lng: -93.4691, city: "Shakopee", state: "MN" },
timezone: "America/Chicago",
website: "https://www.sixflags.com",
@@ -206,6 +225,7 @@ export const PARKS: Park[] = [
shortName: "Worlds of Fun",
chain: "sixflags",
slug: "worldsoffun",
+ region: "Midwest",
location: { lat: 39.1947, lng: -94.5194, city: "Kansas City", state: "MO" },
timezone: "America/Chicago",
website: "https://www.sixflags.com",
@@ -216,6 +236,7 @@ export const PARKS: Park[] = [
shortName: "Michigan's Adventure",
chain: "sixflags",
slug: "miadventure",
+ region: "Midwest",
location: { lat: 43.3281, lng: -86.2694, city: "Muskegon", state: "MI" },
timezone: "America/Detroit",
website: "https://www.sixflags.com",
@@ -226,6 +247,7 @@ export const PARKS: Park[] = [
shortName: "Dorney Park",
chain: "sixflags",
slug: "dorneypark",
+ region: "Northeast",
location: { lat: 40.5649, lng: -75.6063, city: "Allentown", state: "PA" },
timezone: "America/New_York",
website: "https://www.sixflags.com",
@@ -236,6 +258,7 @@ export const PARKS: Park[] = [
shortName: "CA Great America",
chain: "sixflags",
slug: "cagreatamerica",
+ region: "West & International",
location: { lat: 37.3979, lng: -121.9751, city: "Santa Clara", state: "CA" },
timezone: "America/Los_Angeles",
website: "https://www.sixflags.com",
@@ -246,6 +269,7 @@ export const PARKS: Park[] = [
shortName: "Frontier City",
chain: "sixflags",
slug: "frontiercity",
+ region: "Texas & South",
location: { lat: 35.5739, lng: -97.4731, city: "Oklahoma City", state: "OK" },
timezone: "America/Chicago",
website: "https://www.sixflags.com",
@@ -253,3 +277,25 @@ export const PARKS: Park[] = [
];
export const PARK_MAP = new Map(PARKS.map((p) => [p.id, p]));
+
+export const REGIONS = [
+ "Northeast",
+ "Southeast",
+ "Midwest",
+ "Texas & South",
+ "West & International",
+] as const;
+
+export type Region = (typeof REGIONS)[number];
+
+export function groupByRegion(parks: Park[]): Map {
+ const map = new Map(REGIONS.map((r) => [r, []]));
+ for (const park of parks) {
+ map.get(park.region as Region)!.push(park);
+ }
+ // Remove empty regions
+ for (const [region, list] of map) {
+ if (list.length === 0) map.delete(region);
+ }
+ return map;
+}
diff --git a/lib/scrapers/sixflags.ts b/lib/scrapers/sixflags.ts
index a2bc1c4..8947c0d 100644
--- a/lib/scrapers/sixflags.ts
+++ b/lib/scrapers/sixflags.ts
@@ -62,11 +62,26 @@ interface ApiEvent {
extEventName: string;
}
+interface ApiRideDetail {
+ itemID: number;
+ itemName: string;
+ extLocationID: string;
+ operatingTimeFrom: string; // "" or "HH:MM" 24h — empty means not scheduled
+ operatingTimeTo: string;
+}
+
+interface ApiVenue {
+ venueId: number;
+ venueName: string;
+ detailHours: ApiRideDetail[];
+}
+
interface ApiDay {
date: string;
isParkClosed: boolean;
events?: ApiEvent[];
operatings?: ApiOperating[];
+ venues?: ApiVenue[];
}
/** "10:30" → "10:30am", "20:00" → "8pm", "12:00" → "12pm" */
@@ -84,8 +99,15 @@ interface ApiResponse {
dates: ApiDay[];
}
-async function fetchApi(url: string, attempt = 0, totalWaitedMs = 0): Promise {
- const res = await fetch(url, { headers: HEADERS });
+async function fetchApi(
+ url: string,
+ attempt = 0,
+ totalWaitedMs = 0,
+ revalidate?: number,
+): Promise {
+ const fetchOpts: RequestInit & { next?: { revalidate: number } } = { headers: HEADERS };
+ if (revalidate !== undefined) fetchOpts.next = { revalidate };
+ const res = await fetch(url, fetchOpts);
if (res.status === 429 || res.status === 503) {
const retryAfter = res.headers.get("Retry-After");
@@ -96,7 +118,7 @@ async function fetchApi(url: string, attempt = 0, totalWaitedMs = 0): Promise {
const dateParam = `${year}${String(month).padStart(2, "0")}`;
const url = `${API_BASE}/${apiId}?date=${dateParam}`;
- return fetchApi(url);
+ return fetchApi(url, 0, 0, revalidate);
+}
+
+export interface RideStatus {
+ name: string;
+ isOpen: boolean;
+ hoursLabel?: string; // e.g. "10am – 10pm"
+}
+
+export interface RidesFetchResult {
+ rides: RideStatus[];
+ /** The date the ride data actually came from (YYYY-MM-DD). May differ from
+ * the requested date when the API has already dropped the current day and
+ * we fell back to the nearest upcoming open date. */
+ dataDate: string;
+ /** True when dataDate === requested date. False when we fell back. */
+ isExact: boolean;
+ /** Park-level operating hours for the data date (e.g. "10am – 6pm").
+ * Used by the UI to suppress per-ride hours that match the park hours. */
+ parkHoursLabel?: string;
+}
+
+/** Convert "MM/DD/YYYY" API date string to "YYYY-MM-DD". */
+function apiDateToIso(apiDate: string): string {
+ const [mm, dd, yyyy] = apiDate.split("/");
+ return `${yyyy}-${mm}-${dd}`;
+}
+
+/**
+ * Fetch ride operating status for a given date.
+ *
+ * The Six Flags API drops dates that have already started (including today),
+ * returning only tomorrow onwards. When the requested date is missing, we fall
+ * back to the nearest available upcoming date in the same month's response so
+ * 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,
+ * no venues in response).
+ *
+ * Pass `revalidate` (seconds) to enable Next.js ISR caching when called from
+ * a Server Component. Defaults to 1 hour.
+ */
+export async function scrapeRidesForDay(
+ apiId: number,
+ dateIso: string, // YYYY-MM-DD
+ revalidate = 3600,
+): Promise {
+ const [yearStr, monthStr] = dateIso.split("-");
+ const year = parseInt(yearStr);
+ const month = parseInt(monthStr);
+
+ let raw: ApiResponse;
+ try {
+ raw = await scrapeMonthRaw(apiId, year, month, revalidate);
+ } catch {
+ return null;
+ }
+
+ if (!raw.dates.length) return null;
+
+ // The API uses "MM/DD/YYYY" internally.
+ const [, mm, dd] = dateIso.split("-");
+ const apiDate = `${mm}/${dd}/${yearStr}`;
+
+ // Try exact match first; if the API has already dropped today, fall back to
+ // the chronologically nearest available date (always a future date here).
+ let dayData = raw.dates.find((d) => d.date === apiDate);
+ let isExact = true;
+
+ if (!dayData) {
+ // The API drops dates that have already started, so we need a future date.
+ // Prefer the nearest open day; fall back to the nearest date regardless.
+ // If the current month has no more dates (e.g. today is the 30th), also
+ // check next month — a month boundary is not unusual for this case.
+ const futureDates = [...raw.dates]
+ .filter((d) => apiDateToIso(d.date) > dateIso)
+ .sort((a, b) => a.date.localeCompare(b.date));
+
+ dayData = futureDates.find((d) => !d.isParkClosed) ?? futureDates[0];
+
+ if (!dayData) {
+ // Nothing left in the current month — fetch next month.
+ const nextMonthDate = new Date(`${dateIso}T00:00:00`);
+ nextMonthDate.setMonth(nextMonthDate.getMonth() + 1);
+ const nextYear = nextMonthDate.getFullYear();
+ const nextMonth = nextMonthDate.getMonth() + 1;
+ try {
+ const nextRaw = await scrapeMonthRaw(apiId, nextYear, nextMonth, revalidate);
+ const nextSorted = [...nextRaw.dates].sort((a, b) => a.date.localeCompare(b.date));
+ dayData = nextSorted.find((d) => !d.isParkClosed) ?? nextSorted[0];
+ } catch {
+ // If the next month fetch fails, we simply have no fallback data.
+ }
+ }
+
+ isExact = false;
+ }
+
+ if (!dayData) return null;
+
+ // Extract park-level hours from the selected day so the UI can suppress
+ // per-ride hours that simply repeat what the park is already showing.
+ const parkOperating =
+ dayData.operatings?.find((o) => o.operatingTypeName === "Park") ??
+ dayData.operatings?.[0];
+ const parkItem = parkOperating?.items?.[0];
+ const parkHoursLabel =
+ parkItem?.timeFrom && parkItem?.timeTo
+ ? `${fmt24(parkItem.timeFrom)} – ${fmt24(parkItem.timeTo)}`
+ : undefined;
+
+ const rides: RideStatus[] = [];
+ for (const venue of (dayData.venues ?? []).filter((v) => v.venueName === "Rides")) {
+ for (const ride of venue.detailHours ?? []) {
+ if (!ride.itemName) continue;
+ const isOpen = Boolean(ride.operatingTimeFrom && ride.operatingTimeTo);
+ const hoursLabel = isOpen
+ ? `${fmt24(ride.operatingTimeFrom)} – ${fmt24(ride.operatingTimeTo)}`
+ : undefined;
+ rides.push({ name: ride.itemName, isOpen, hoursLabel });
+ }
+ }
+
+ if (rides.length === 0) return null;
+
+ // Sort: open rides first, then alphabetical within each group
+ rides.sort((a, b) => {
+ if (a.isOpen !== b.isOpen) return a.isOpen ? -1 : 1;
+ return a.name.localeCompare(b.name);
+ });
+
+ return { rides, dataDate: apiDateToIso(dayData.date), isExact, parkHoursLabel };
}
/**
diff --git a/lib/scrapers/types.ts b/lib/scrapers/types.ts
index 1197551..242eae3 100644
--- a/lib/scrapers/types.ts
+++ b/lib/scrapers/types.ts
@@ -4,6 +4,7 @@ export interface Park {
shortName: string;
chain: "sixflags" | string;
slug: string;
+ region: "Northeast" | "Southeast" | "Midwest" | "Texas & South" | "West & International";
location: {
lat: number;
lng: number;