feat: UI redesign with park detail pages and ride status
Some checks failed
Build and Deploy / Build & Push (push) Failing after 22s
Some checks failed
Build and Deploy / Build & Push (push) Failing after 22s
Visual overhaul: - Warmer color system with amber accent for Today, better text hierarchy - Row hover highlighting, sticky column shadow on horizontal scroll - Closed cells replaced with dot (·) instead of "Closed" text - Regional grouping (Northeast/Southeast/Midwest/Texas & South/West) - Two-row header with park count badge and WeekNav on separate lines - Amber "Today" button in WeekNav when off current week - Mobile card layout (< 1024px) with 7-day grid per park; table on desktop - Skeleton loading state via app/loading.tsx Park detail pages (/park/[id]): - Month calendar view with ← → navigation via ?month= param - Live ride status fetched from Six Flags API (cached 1h) - Ride hours only shown when they differ from park operating hours - Fallback to nearest upcoming open day when today is dropped by API, including cross-month fallback for end-of-month edge case Data layer: - Park type gains region field; parks.ts exports groupByRegion() - db.ts gains getParkMonthData() for single-park month queries - sixflags.ts gains scrapeRidesForDay() returning RidesFetchResult with rides, dataDate, isExact, and parkHoursLabel Removed: CalendarGrid.tsx, MonthNav.tsx (dead code) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
36
lib/db.ts
36
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<string, DayData> {
|
||||
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<string, DayData> = {};
|
||||
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,
|
||||
|
||||
46
lib/parks.ts
46
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<string, Park>(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<Region, Park[]> {
|
||||
const map = new Map<Region, Park[]>(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;
|
||||
}
|
||||
|
||||
@@ -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<ApiResponse> {
|
||||
const res = await fetch(url, { headers: HEADERS });
|
||||
async function fetchApi(
|
||||
url: string,
|
||||
attempt = 0,
|
||||
totalWaitedMs = 0,
|
||||
revalidate?: number,
|
||||
): Promise<ApiResponse> {
|
||||
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<Ap
|
||||
` [rate-limited] HTTP ${res.status} — waiting ${waitMs / 1000}s (attempt ${attempt + 1}/${MAX_RETRIES})`
|
||||
);
|
||||
await sleep(waitMs);
|
||||
if (attempt < MAX_RETRIES) return fetchApi(url, attempt + 1, totalWaitedMs + waitMs);
|
||||
if (attempt < MAX_RETRIES) return fetchApi(url, attempt + 1, totalWaitedMs + waitMs, revalidate);
|
||||
throw new RateLimitError(totalWaitedMs + waitMs);
|
||||
}
|
||||
|
||||
@@ -105,16 +127,149 @@ async function fetchApi(url: string, attempt = 0, totalWaitedMs = 0): Promise<Ap
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the raw API response for a month — used by scripts/debug.ts.
|
||||
* Fetch the raw API response for a month — used by scripts/debug.ts and the park detail page.
|
||||
* Pass `revalidate` (seconds) to enable Next.js ISR caching when called from a Server Component.
|
||||
*/
|
||||
export async function scrapeMonthRaw(
|
||||
apiId: number,
|
||||
year: number,
|
||||
month: number
|
||||
month: number,
|
||||
revalidate?: number,
|
||||
): Promise<ApiResponse> {
|
||||
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<RidesFetchResult | null> {
|
||||
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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user