diff --git a/app/globals.css b/app/globals.css index 55ac505..c3e9ce1 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,29 +1,49 @@ @import "tailwindcss"; @theme { - --color-bg: #0a0f1e; - --color-surface: #111827; - --color-surface-2: #1a2235; + /* ── Backgrounds ─────────────────────────────────────────────────────────── */ + --color-bg: #0c1220; + --color-surface: #141c2e; + --color-surface-2: #1c2640; + --color-surface-hover: #222e4a; --color-border: #1f2d45; + --color-border-subtle: #172035; + + /* ── Text ────────────────────────────────────────────────────────────────── */ --color-text: #f1f5f9; + --color-text-secondary: #94a3b8; --color-text-muted: #64748b; - --color-text-dim: #334155; + --color-text-dim: #475569; + /* ── Warm accent (Today / active states) ─────────────────────────────────── */ + --color-accent: #f59e0b; + --color-accent-hover: #d97706; + --color-accent-text: #fef3c7; + --color-accent-muted: #78350f; + + /* ── Open (green) ────────────────────────────────────────────────────────── */ --color-open-bg: #052e16; - --color-open-border: #166534; + --color-open-border: #16a34a; --color-open-text: #4ade80; - --color-open-hours: #bbf7d0; + --color-open-hours: #dcfce7; + /* ── Passholder preview (purple) ─────────────────────────────────────────── */ --color-ph-bg: #1e0f2e; --color-ph-border: #7e22ce; --color-ph-hours: #e9d5ff; --color-ph-label: #c084fc; - --color-today-bg: #0c1a3d; - --color-today-border: #2563eb; - --color-today-text: #93c5fd; + /* ── Today column (amber instead of cold blue) ───────────────────────────── */ + --color-today-bg: #1c1a0e; + --color-today-border: #f59e0b; + --color-today-text: #fde68a; + /* ── Weekend header ──────────────────────────────────────────────────────── */ --color-weekend-header: #141f35; + + /* ── Region header ───────────────────────────────────────────────────────── */ + --color-region-bg: #0e1628; + --color-region-accent: #334155; } :root { @@ -38,6 +58,7 @@ padding: 0; } +/* ── Scrollbar ───────────────────────────────────────────────────────────── */ ::-webkit-scrollbar { width: 6px; height: 6px; @@ -49,3 +70,39 @@ background: var(--color-border); border-radius: 3px; } +::-webkit-scrollbar-thumb:hover { + background: var(--color-text-muted); +} + +/* ── Sticky column shadow when scrolling ─────────────────────────────────── */ +.sticky-shadow { + box-shadow: 4px 0 16px -2px rgba(0, 0, 0, 0.5); + clip-path: inset(0 -16px 0 0); +} + +/* ── Park row hover (group/group-hover via Tailwind not enough across sticky cols) */ +.park-row:hover td, +.park-row:hover th { + background-color: var(--color-surface-hover) !important; +} + +/* ── Park name link hover ────────────────────────────────────────────────── */ +.park-name-link { + text-decoration: none; + color: inherit; + transition: color 120ms ease; +} +.park-name-link:hover { + color: var(--color-accent); +} + +/* ── Pulse animation for skeleton ───────────────────────────────────────── */ +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} +.skeleton { + animation: pulse 1.8s ease-in-out infinite; + background-color: var(--color-surface); + border-radius: 4px; +} diff --git a/app/loading.tsx b/app/loading.tsx new file mode 100644 index 0000000..8ad1f8f --- /dev/null +++ b/app/loading.tsx @@ -0,0 +1,82 @@ +// Next.js automatically shows this while the page Server Component fetches data. +// No client JS — the skeleton is pure HTML + CSS animation. + +const SKELETON_ROWS = 8; +const SKELETON_COLS = 7; + +export default function Loading() { + return ( +
+ {/* Skeleton header */} +
+
+
+
+
+
+
+
+
+
+
+ + {/* Skeleton table — desktop */} +
+
+ + + + {Array.from({ length: SKELETON_COLS }).map((_, i) => ( + + ))} + + + {/* Column headers */} + + + + {Array.from({ length: SKELETON_COLS }).map((_, i) => ( + + ))} + + + + {/* Skeleton rows */} + + {Array.from({ length: SKELETON_ROWS }).map((_, rowIdx) => ( + + + {Array.from({ length: SKELETON_COLS }).map((_, colIdx) => ( + + ))} + + ))} + +
+
+
+
+
+
+
+
+
+
+
+
+
+ {(rowIdx + colIdx) % 3 === 0 && ( +
+ )} +
+
+
+
+ ); +} diff --git a/app/page.tsx b/app/page.tsx index a3cf134..9a1b3fa 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,6 +1,9 @@ import { WeekCalendar } from "@/components/WeekCalendar"; +import { MobileCardList } from "@/components/MobileCardList"; import { WeekNav } from "@/components/WeekNav"; -import { PARKS } from "@/lib/parks"; +import { Legend } from "@/components/Legend"; +import { EmptyState } from "@/components/EmptyState"; +import { PARKS, groupByRegion } from "@/lib/parks"; import { openDb, getDateRange } from "@/lib/db"; interface PageProps { @@ -11,7 +14,6 @@ function getWeekStart(param: string | undefined): string { if (param && /^\d{4}-\d{2}-\d{2}$/.test(param)) { const d = new Date(param + "T00:00:00"); if (!isNaN(d.getTime())) { - // Snap to Sunday d.setDate(d.getDate() - d.getDay()); return d.toISOString().slice(0, 10); } @@ -29,114 +31,121 @@ function getWeekDates(sundayIso: string): string[] { }); } +function getCurrentWeekStart(): string { + const today = new Date(); + today.setDate(today.getDate() - today.getDay()); + return today.toISOString().slice(0, 10); +} + export default async function HomePage({ searchParams }: PageProps) { const params = await searchParams; const weekStart = getWeekStart(params.week); const weekDates = getWeekDates(weekStart); const endDate = weekDates[6]; + const today = new Date().toISOString().slice(0, 10); + const isCurrentWeek = weekStart === getCurrentWeekStart(); const db = openDb(); const data = getDateRange(db, weekStart, endDate); db.close(); - // Count how many days have any scraped data (to show empty state) const scrapedCount = Object.values(data).reduce( (sum, parkData) => sum + Object.keys(parkData).length, 0 ); - // Only show parks that have at least one open day this week const visibleParks = PARKS.filter((park) => weekDates.some((date) => data[park.id]?.[date]?.isOpen) ); + const grouped = groupByRegion(visibleParks); + return (
- {/* Header */} + {/* ── Header ─────────────────────────────────────────────────────────── */}
-
- + {/* Row 1: Title + park count */} +
+ Six Flags Calendar - + + {visibleParks.length} of {PARKS.length} parks open
- - - + {/* Row 2: Week nav + legend */} +
+ + +
- {/* Calendar */} -
+ {/* ── Main content ───────────────────────────────────────────────────── */} +
{scrapedCount === 0 ? ( ) : ( - + <> + {/* Mobile: card list (hidden on lg+) */} +
+ +
+ + {/* Desktop: week table (hidden below lg) */} +
+ +
+ )}
); } - -function Legend() { - return ( -
- - - Open - - - Closed - · - — no data - -
- ); -} - -function EmptyState() { - return ( -
-
📅
-
No data scraped yet
-
- Run the following to populate the calendar: -
-
-        npm run discover{"\n"}npm run scrape
-      
-
- ); -} diff --git a/app/park/[id]/page.tsx b/app/park/[id]/page.tsx new file mode 100644 index 0000000..2bc9c87 --- /dev/null +++ b/app/park/[id]/page.tsx @@ -0,0 +1,309 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { PARK_MAP } from "@/lib/parks"; +import { openDb, getParkMonthData, getApiId } from "@/lib/db"; +import { scrapeRidesForDay } from "@/lib/scrapers/sixflags"; +import { ParkMonthCalendar } from "@/components/ParkMonthCalendar"; +import type { RideStatus, RidesFetchResult } from "@/lib/scrapers/sixflags"; + +interface PageProps { + params: Promise<{ id: string }>; + searchParams: Promise<{ month?: string }>; +} + +function parseMonthParam(param: string | undefined): { year: number; month: number } { + if (param && /^\d{4}-\d{2}$/.test(param)) { + const [y, m] = param.split("-").map(Number); + if (y >= 2020 && y <= 2030 && m >= 1 && m <= 12) { + return { year: y, month: m }; + } + } + const now = new Date(); + return { year: now.getFullYear(), month: now.getMonth() + 1 }; +} + +export default async function ParkPage({ params, searchParams }: PageProps) { + const { id } = await params; + const { month: monthParam } = await searchParams; + + const park = PARK_MAP.get(id); + if (!park) notFound(); + + const today = new Date().toISOString().slice(0, 10); + const { year, month } = parseMonthParam(monthParam); + + const db = openDb(); + const monthData = getParkMonthData(db, id, year, month); + const apiId = getApiId(db, id); + db.close(); + + // Fetch live ride data — cached 1h via Next.js ISR. + // Note: the API drops today's date from its response (only returns future dates), + // so scrapeRidesForDay may fall back to the nearest upcoming date. + let ridesResult: RidesFetchResult | null = null; + if (apiId !== null) { + ridesResult = await scrapeRidesForDay(apiId, today); + } + + const todayData = monthData[today]; + const parkOpenToday = todayData?.isOpen && todayData?.hoursLabel; + + return ( +
+ {/* ── Header ─────────────────────────────────────────────────────────── */} +
+ + ← Calendar + +
+ + {park.name} + + + {park.location.city}, {park.location.state} + +
+ +
+ + {/* ── Month Calendar ───────────────────────────────────────────────── */} +
+ +
+ + {/* ── Ride Status ─────────────────────────────────────────────────── */} +
+ + Rides + + {ridesResult && !ridesResult.isExact + ? formatShortDate(ridesResult.dataDate) + : "Today"} + + + + +
+
+
+ ); +} + +// ── Helpers ──────────────────────────────────────────────────────────────── + +function formatDate(iso: string): string { + return new Date(iso + "T00:00:00").toLocaleDateString("en-US", { + weekday: "long", month: "long", day: "numeric", + }); +} + +function formatShortDate(iso: string): string { + return new Date(iso + "T00:00:00").toLocaleDateString("en-US", { + weekday: "short", month: "short", day: "numeric", + }); +} + +// ── Sub-components ───────────────────────────────────────────────────────── + +function SectionHeading({ children }: { children: React.ReactNode }) { + return ( +
+

+ {children} +

+
+ ); +} + +function RideList({ + ridesResult, + parkOpenToday, + apiIdMissing, +}: { + ridesResult: RidesFetchResult | null; + parkOpenToday: boolean; + apiIdMissing: boolean; +}) { + if (apiIdMissing) { + return ( + + Park API ID not discovered yet. Run{" "} + + npm run discover + {" "} + to enable ride data. + + ); + } + + if (!parkOpenToday) { + return Park is closed today — no ride schedule available.; + } + + if (!ridesResult || ridesResult.rides.length === 0) { + return Ride schedule is not yet available from the API.; + } + + const { rides, isExact, dataDate, parkHoursLabel } = ridesResult; + const openRides = rides.filter((r) => r.isOpen); + const closedRides = rides.filter((r) => !r.isOpen); + + return ( +
+ {/* Summary badge row */} +
+
+ {openRides.length} open +
+ {closedRides.length > 0 && ( +
+ {closedRides.length} closed / unscheduled +
+ )} + {!isExact && ( + + Showing {formatShortDate(dataDate)} — live schedule updates daily + + )} +
+ + {/* Two-column grid */} +
+ {openRides.map((ride) => )} + {closedRides.map((ride) => )} +
+
+ ); +} + +function RideRow({ ride, parkHoursLabel }: { ride: RideStatus; parkHoursLabel?: string }) { + // Only show the ride's hours when they differ from the park's overall hours. + // This avoids repeating "10am – 6pm" on every single row when that's the + // default — but surfaces exceptions like "11am – 4pm" for Safari tours, etc. + const showHours = ride.isOpen && ride.hoursLabel && ride.hoursLabel !== parkHoursLabel; + + return ( +
+
+ + + {ride.name} + +
+ {showHours && ( + + {ride.hoursLabel} + + )} +
+ ); +} + +function Callout({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} diff --git a/components/CalendarGrid.tsx b/components/CalendarGrid.tsx deleted file mode 100644 index 3f9a6d5..0000000 --- a/components/CalendarGrid.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import type { Park } from "@/lib/scrapers/types"; - -interface CalendarGridProps { - parks: Park[]; - calendar: Record; - daysInMonth: number; - year: number; - month: number; -} - -const DOW_LABELS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"]; - -export function CalendarGrid({ - parks, - calendar, - daysInMonth, - year, - month, -}: CalendarGridProps) { - const days = Array.from({ length: daysInMonth }, (_, i) => i + 1); - const today = new Date(); - const todayDay = - today.getFullYear() === year && today.getMonth() + 1 === month - ? today.getDate() - : null; - - return ( -
- - - - - {days.map((day) => { - const dow = new Date(year, month - 1, day).getDay(); - const isWeekend = dow === 0 || dow === 6; - const isToday = day === todayDay; - return ( - - ); - })} - - - - {parks.map((park) => { - const parkDays = calendar[park.id] ?? []; - return ( - - - {days.map((day) => { - const isOpen = parkDays[day - 1] ?? false; - const isToday = day === todayDay; - return ( - - ); - })} - - ); - })} - -
- Park - -
{DOW_LABELS[dow]}
-
- {day} -
-
- {park.shortName} - - -
-
- ); -} diff --git a/components/EmptyState.tsx b/components/EmptyState.tsx new file mode 100644 index 0000000..2afbabd --- /dev/null +++ b/components/EmptyState.tsx @@ -0,0 +1,30 @@ +export function EmptyState() { + return ( +
+
📅
+
No data scraped yet
+
+ Run the following to populate the calendar: +
+
+        npm run discover{"\n"}npm run scrape
+      
+
+ ); +} diff --git a/components/Legend.tsx b/components/Legend.tsx new file mode 100644 index 0000000..30c2b9c --- /dev/null +++ b/components/Legend.tsx @@ -0,0 +1,33 @@ +export function Legend() { + return ( +
+ + + Open + + + + Passholder + + + · + Closed + · + — no data + +
+ ); +} diff --git a/components/MobileCardList.tsx b/components/MobileCardList.tsx new file mode 100644 index 0000000..8c4ad95 --- /dev/null +++ b/components/MobileCardList.tsx @@ -0,0 +1,60 @@ +import type { Park } from "@/lib/scrapers/types"; +import type { DayData } from "@/lib/db"; +import type { Region } from "@/lib/parks"; +import { ParkCard } from "./ParkCard"; + +interface MobileCardListProps { + grouped: Map; + weekDates: string[]; + data: Record>; + today: string; +} + +export function MobileCardList({ grouped, weekDates, data, today }: MobileCardListProps) { + return ( +
+ {Array.from(grouped.entries()).map(([region, parks]) => ( +
+ {/* Region heading */} +
+
+ + {region} + +
+ + {/* Park cards */} +
+ {parks.map((park) => ( + + ))} +
+
+ ))} +
+ ); +} diff --git a/components/MonthNav.tsx b/components/MonthNav.tsx deleted file mode 100644 index 911f46f..0000000 --- a/components/MonthNav.tsx +++ /dev/null @@ -1,55 +0,0 @@ -"use client"; - -import { useRouter } from "next/navigation"; - -interface MonthNavProps { - currentYear: number; - currentMonth: number; -} - -const MONTH_NAMES = [ - "January", "February", "March", "April", "May", "June", - "July", "August", "September", "October", "November", "December", -]; - -function addMonths(year: number, month: number, delta: number) { - const d = new Date(year, month - 1 + delta, 1); - return { year: d.getFullYear(), month: d.getMonth() + 1 }; -} - -function formatParam(year: number, month: number) { - return `${year}-${String(month).padStart(2, "0")}`; -} - -export function MonthNav({ currentYear, currentMonth }: MonthNavProps) { - const router = useRouter(); - - function navigate(delta: -1 | 1) { - const { year, month } = addMonths(currentYear, currentMonth, delta); - router.push(`/?month=${formatParam(year, month)}`); - } - - const btnStyle = { - backgroundColor: "var(--color-surface)", - border: "1px solid var(--color-border)", - color: "var(--color-text-muted)", - padding: "4px 12px", - borderRadius: "6px", - cursor: "pointer", - fontSize: "1rem", - }; - - return ( -
- -

- {MONTH_NAMES[currentMonth - 1]} {currentYear} -

- -
- ); -} diff --git a/components/ParkCard.tsx b/components/ParkCard.tsx new file mode 100644 index 0000000..836eb43 --- /dev/null +++ b/components/ParkCard.tsx @@ -0,0 +1,170 @@ +import Link from "next/link"; +import type { Park } from "@/lib/scrapers/types"; +import type { DayData } from "@/lib/db"; + +interface ParkCardProps { + park: Park; + weekDates: string[]; // 7 dates YYYY-MM-DD + parkData: Record; + today: string; +} + +const DOW_SHORT = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"]; + +function parseDate(iso: string) { + const d = new Date(iso + "T00:00:00"); + return { day: d.getDate(), dow: d.getDay(), isWeekend: d.getDay() === 0 || d.getDay() === 6 }; +} + +export function ParkCard({ park, weekDates, parkData, today }: ParkCardProps) { + return ( +
+ {/* Park name + location */} +
+ + + {park.name} + + +
+ {park.location.city}, {park.location.state} +
+
+ + {/* 7-day grid */} +
+ {weekDates.map((date) => { + const pd = parseDate(date); + const isToday = date === today; + const dayData = parkData[date]; + const isOpen = dayData?.isOpen && dayData?.hoursLabel; + const isPH = dayData?.specialType === "passholder_preview"; + + let cellBg = "transparent"; + let cellBorder = "1px solid var(--color-border-subtle)"; + let cellBorderRadius = "6px"; + + if (isToday) { + cellBg = "var(--color-today-bg)"; + cellBorder = `1px solid var(--color-today-border)`; + } else if (pd.isWeekend) { + cellBg = "var(--color-weekend-header)"; + } + + return ( +
+ {/* Day name */} + + {DOW_SHORT[pd.dow]} + + + {/* Date number */} + + {pd.day} + + + {/* Status */} + {!dayData ? ( + + ) : isPH && isOpen ? ( + + PH + + ) : isOpen ? ( + + Open + + ) : ( + · + )} +
+ ); + })} +
+ + {/* Hours detail row — show the open day hours inline */} + {weekDates.some((d) => parkData[d]?.isOpen && parkData[d]?.hoursLabel) && ( +
+ {weekDates.map((date) => { + const pd = parseDate(date); + const dayData = parkData[date]; + if (!dayData?.isOpen || !dayData?.hoursLabel) return null; + const isPH = dayData.specialType === "passholder_preview"; + return ( + + + {DOW_SHORT[pd.dow]} + + {dayData.hoursLabel} + {isPH && ( + PH + )} + + ); + })} +
+ )} +
+ ); +} diff --git a/components/ParkMonthCalendar.tsx b/components/ParkMonthCalendar.tsx new file mode 100644 index 0000000..14e5cb7 --- /dev/null +++ b/components/ParkMonthCalendar.tsx @@ -0,0 +1,226 @@ +import Link from "next/link"; +import type { DayData } from "@/lib/db"; + +interface ParkMonthCalendarProps { + parkId: string; + year: number; + month: number; // 1-indexed + monthData: Record; // 'YYYY-MM-DD' → DayData + today: string; // YYYY-MM-DD +} + +const DOW_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; +const MONTH_NAMES = [ + "January","February","March","April","May","June", + "July","August","September","October","November","December", +]; + +function prevMonth(year: number, month: number) { + if (month === 1) return { year: year - 1, month: 12 }; + return { year, month: month - 1 }; +} + +function nextMonth(year: number, month: number) { + if (month === 12) return { year: year + 1, month: 1 }; + return { year, month: month + 1 }; +} + +function daysInMonth(year: number, month: number): number { + return new Date(year, month, 0).getDate(); +} + +export function ParkMonthCalendar({ parkId, year, month, monthData, today }: ParkMonthCalendarProps) { + const firstDow = new Date(year, month - 1, 1).getDay(); // 0=Sun + const totalDays = daysInMonth(year, month); + + const prev = prevMonth(year, month); + const next = nextMonth(year, month); + const prevParam = `${prev.year}-${String(prev.month).padStart(2, "0")}`; + const nextParam = `${next.year}-${String(next.month).padStart(2, "0")}`; + + // Build cells: leading empty + actual days + const cells: Array<{ day: number | null; iso: string | null }> = []; + for (let i = 0; i < firstDow; i++) cells.push({ day: null, iso: null }); + for (let d = 1; d <= totalDays; d++) { + const iso = `${year}-${String(month).padStart(2, "0")}-${String(d).padStart(2, "0")}`; + cells.push({ day: d, iso }); + } + // Pad to complete last row + while (cells.length % 7 !== 0) cells.push({ day: null, iso: null }); + + const weeks: typeof cells[] = []; + for (let i = 0; i < cells.length; i += 7) { + weeks.push(cells.slice(i, i + 7)); + } + + return ( +
+ {/* Month nav */} +
+ + ← + + + {MONTH_NAMES[month - 1]} {year} + + + → + +
+ + {/* Calendar grid */} +
+ {/* DOW header */} +
+ {DOW_LABELS.map((d, i) => ( +
+ {d} +
+ ))} +
+ + {/* Weeks */} + {weeks.map((week, wi) => ( +
+ {week.map((cell, ci) => { + if (!cell.day || !cell.iso) { + return ( +
+ ); + } + + const dayData = monthData[cell.iso]; + const isToday = cell.iso === today; + const isWeekend = ci === 0 || ci === 6; + const isOpen = dayData?.isOpen && dayData?.hoursLabel; + const isPH = dayData?.specialType === "passholder_preview"; + + let bg = isToday + ? "var(--color-today-bg)" + : isWeekend + ? "var(--color-weekend-header)" + : "transparent"; + + return ( +
+ {/* Date number */} + + {cell.day} + + + {/* Status */} + {!dayData ? ( + + ) : isPH && isOpen ? ( +
+
+ Passholder +
+
+ {dayData.hoursLabel} +
+
+ ) : isOpen ? ( +
+
+ {dayData.hoursLabel} +
+
+ ) : ( + · + )} +
+ ); + })} +
+ ))} +
+
+ ); +} + +const navLinkStyle: React.CSSProperties = { + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + padding: "6px 14px", + borderRadius: 6, + border: "1px solid var(--color-border)", + background: "var(--color-surface)", + color: "var(--color-text-muted)", + fontSize: "1rem", + lineHeight: 1, + textDecoration: "none", +}; diff --git a/components/WeekCalendar.tsx b/components/WeekCalendar.tsx index de1bf43..a1cb6c4 100644 --- a/components/WeekCalendar.tsx +++ b/components/WeekCalendar.tsx @@ -1,10 +1,14 @@ +import { Fragment } from "react"; +import Link from "next/link"; import type { Park } from "@/lib/scrapers/types"; import type { DayData } from "@/lib/db"; +import type { Region } from "@/lib/parks"; interface WeekCalendarProps { parks: Park[]; weekDates: string[]; // 7 dates, YYYY-MM-DD, Sun–Sat data: Record>; // parkId → date → DayData + grouped?: Map; // pre-grouped parks (if provided, renders region headers) } const DOW = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; @@ -13,7 +17,6 @@ const MONTHS = [ "Jul","Aug","Sep","Oct","Nov","Dec", ]; - function parseDate(iso: string) { const d = new Date(iso + "T00:00:00"); return { @@ -24,14 +27,217 @@ function parseDate(iso: string) { }; } -export function WeekCalendar({ parks, weekDates, data }: WeekCalendarProps) { +function DayCell({ + date, + dayData, + isToday, + isWeekend, +}: { + date: string; + dayData: DayData | undefined; + isToday: boolean; + isWeekend: boolean; +}) { + const base: React.CSSProperties = { + padding: 0, + textAlign: "center", + verticalAlign: "middle", + borderBottom: "1px solid var(--color-border)", + borderLeft: "1px solid var(--color-border)", + height: 56, + background: isToday + ? "var(--color-today-bg)" + : isWeekend + ? "var(--color-weekend-header)" + : "transparent", + borderLeftColor: isToday ? "var(--color-today-border)" : undefined, + borderRightColor: isToday ? "var(--color-today-border)" : undefined, + borderRight: isToday ? "1px solid var(--color-today-border)" : undefined, + transition: "background 120ms ease", + }; + + if (!dayData) { + return ( + + + + ); + } + + if (!dayData.isOpen || !dayData.hoursLabel) { + return ( + + · + + ); + } + + if (dayData.specialType === "passholder_preview") { + return ( + +
+ + Passholder + + + {dayData.hoursLabel} + +
+ + ); + } + + return ( + +
+ + {dayData.hoursLabel} + +
+ + ); +} + +function RegionHeader({ region, colSpan }: { region: string; colSpan: number }) { + return ( + + + + {region} + + + + ); +} + +function ParkRow({ + park, + parkIdx, + weekDates, + parsedDates, + parkData, + today, +}: { + park: Park; + parkIdx: number; + weekDates: string[]; + parsedDates: ReturnType[]; + parkData: Record; + today: string; +}) { + const rowBg = parkIdx % 2 === 0 ? "var(--color-bg)" : "var(--color-surface)"; + return ( + + + + + {park.name} + + +
+ {park.location.city}, {park.location.state} +
+ + + {weekDates.map((date, i) => ( + + ))} + + ); +} + +export function WeekCalendar({ parks, weekDates, data, grouped }: WeekCalendarProps) { const today = new Date().toISOString().slice(0, 10); const parsedDates = weekDates.map(parseDate); - // Detect month boundaries for column headers const firstMonth = parsedDates[0].month; const firstYear = new Date(weekDates[0] + "T00:00:00").getFullYear(); + // Build ordered list of (region, parks[]) if grouped, otherwise treat all as one group + const sections: Array<{ region: string | null; parks: Park[] }> = grouped + ? Array.from(grouped.entries()).map(([region, ps]) => ({ region, parks: ps })) + : [{ region: null, parks }]; + + const colSpan = weekDates.length + 1; // park col + 7 day cols + return (
- {/* Park name column */} - + {weekDates.map((d) => ( - + ))} - {/* Month header — only show if we cross a month boundary */} + {/* Month header — only when week crosses a month boundary */} {parsedDates.some((d) => d.month !== firstMonth) && ( @@ -99,16 +303,16 @@ export function WeekCalendar({ parks, weekDates, data }: WeekCalendarProps) { : `1px solid var(--color-border)`, }}>
{DOW[pd.dow]}
@@ -121,143 +325,24 @@ export function WeekCalendar({ parks, weekDates, data }: WeekCalendarProps) {
- {parks.map((park, parkIdx) => { - const parkData = data[park.id] ?? {}; - return ( - - {/* Park name */} - - - {/* Day cells */} - {weekDates.map((date, i) => { - const pd = parsedDates[i]; - const isToday = date === today; - const dayData = parkData[date]; - - if (!dayData) { - // No data scraped yet - return ( - - ); - } - - // Treat open-but-no-hours the same as closed (stale data or missing hours) - if (!dayData.isOpen || !dayData.hoursLabel) { - return ( - - ); - } - - // Passholder preview day - if (dayData.specialType === "passholder_preview") { - return ( - - ); - } - - // Open with confirmed hours - return ( - - ); - })} - - ); - })} + {sections.map(({ region, parks: sectionParks }) => ( + + {region && ( + + )} + {sectionParks.map((park, parkIdx) => ( + + ))} + + ))}
{weekDates.map((date, i) => { const pd = parsedDates[i]; - // Only render the month label on the first day of each new month (or the first column) const showMonth = i === 0 || pd.month !== parsedDates[i - 1].month; return ( - + Park
-
- {park.name} -
-
- {park.location.city}, {park.location.state} -
-
- - - Closed - -
- - Passholder - - - {dayData.hoursLabel} - -
-
-
- - {dayData.hoursLabel} - -
-
@@ -270,7 +355,7 @@ const thParkStyle: React.CSSProperties = { position: "sticky", left: 0, zIndex: 10, - padding: "10px 12px", + padding: "10px 14px", textAlign: "left", borderBottom: "1px solid var(--color-border)", background: "var(--color-bg)", @@ -285,23 +370,3 @@ const thDayStyle: React.CSSProperties = { borderLeft: "1px solid var(--color-border)", verticalAlign: "bottom", }; - -const tdParkStyle: React.CSSProperties = { - position: "sticky", - left: 0, - zIndex: 5, - padding: "10px 12px", - borderBottom: "1px solid var(--color-border)", - borderRight: "1px solid var(--color-border)", - whiteSpace: "nowrap", - verticalAlign: "middle", -}; - -const tdBase: React.CSSProperties = { - padding: 0, - textAlign: "center", - verticalAlign: "middle", - borderBottom: "1px solid var(--color-border)", - borderLeft: "1px solid var(--color-border)", - height: 52, -}; diff --git a/components/WeekNav.tsx b/components/WeekNav.tsx index c50d7c5..766b274 100644 --- a/components/WeekNav.tsx +++ b/components/WeekNav.tsx @@ -3,8 +3,9 @@ import { useRouter } from "next/navigation"; interface WeekNavProps { - weekStart: string; // YYYY-MM-DD (Sunday) + weekStart: string; // YYYY-MM-DD (Sunday) weekDates: string[]; // 7 dates YYYY-MM-DD + isCurrentWeek: boolean; } const MONTHS = [ @@ -29,27 +30,43 @@ function shiftWeek(weekStart: string, delta: number): string { return d.toISOString().slice(0, 10); } -export function WeekNav({ weekStart, weekDates }: WeekNavProps) { +export function WeekNav({ weekStart, weekDates, isCurrentWeek }: WeekNavProps) { const router = useRouter(); const nav = (delta: number) => router.push(`/?week=${shiftWeek(weekStart, delta)}`); return ( -
+
+ {!isCurrentWeek && ( + + )} + {formatLabel(weekDates)} @@ -57,7 +74,9 @@ export function WeekNav({ weekStart, weekDates }: WeekNavProps) { @@ -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;