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:
@@ -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;
|
||||
}
|
||||
|
||||
82
app/loading.tsx
Normal file
82
app/loading.tsx
Normal file
@@ -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 (
|
||||
<div style={{ minHeight: "100vh", background: "var(--color-bg)" }}>
|
||||
{/* Skeleton header */}
|
||||
<header style={{
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
zIndex: 20,
|
||||
background: "var(--color-bg)",
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
}}>
|
||||
<div style={{ padding: "12px 24px 10px", display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||
<div className="skeleton" style={{ width: 180, height: 22, borderRadius: 6 }} />
|
||||
<div className="skeleton" style={{ width: 120, height: 26, borderRadius: 20 }} />
|
||||
</div>
|
||||
<div style={{ padding: "8px 24px 10px", borderTop: "1px solid var(--color-border-subtle)", display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<div className="skeleton" style={{ width: 32, height: 32, borderRadius: 6 }} />
|
||||
<div className="skeleton" style={{ width: 200, height: 20, borderRadius: 4 }} />
|
||||
<div className="skeleton" style={{ width: 32, height: 32, borderRadius: 6 }} />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Skeleton table — desktop */}
|
||||
<main style={{ padding: "0 24px 48px" }}>
|
||||
<div style={{ overflowX: "auto", marginTop: 0 }}>
|
||||
<table style={{ borderCollapse: "collapse", width: "100%", minWidth: 700, tableLayout: "fixed" }}>
|
||||
<colgroup>
|
||||
<col style={{ width: 240 }} />
|
||||
{Array.from({ length: SKELETON_COLS }).map((_, i) => (
|
||||
<col key={i} style={{ width: 130 }} />
|
||||
))}
|
||||
</colgroup>
|
||||
|
||||
{/* Column headers */}
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ padding: "10px 14px", borderBottom: "1px solid var(--color-border)" }}>
|
||||
<div className="skeleton" style={{ width: 40, height: 12, borderRadius: 3 }} />
|
||||
</th>
|
||||
{Array.from({ length: SKELETON_COLS }).map((_, i) => (
|
||||
<th key={i} style={{ padding: "10px 8px", borderBottom: "1px solid var(--color-border)", borderLeft: "1px solid var(--color-border)", textAlign: "center" }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 4 }}>
|
||||
<div className="skeleton" style={{ width: 24, height: 10, borderRadius: 3 }} />
|
||||
<div className="skeleton" style={{ width: 18, height: 18, borderRadius: 3 }} />
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
{/* Skeleton rows */}
|
||||
<tbody>
|
||||
{Array.from({ length: SKELETON_ROWS }).map((_, rowIdx) => (
|
||||
<tr key={rowIdx} style={{ background: rowIdx % 2 === 0 ? "var(--color-bg)" : "var(--color-surface)" }}>
|
||||
<td style={{ padding: "10px 14px", borderBottom: "1px solid var(--color-border)", borderRight: "1px solid var(--color-border)" }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
|
||||
<div className="skeleton" style={{ width: `${140 + (rowIdx % 3) * 20}px`, height: 13, borderRadius: 3 }} />
|
||||
<div className="skeleton" style={{ width: 80, height: 10, borderRadius: 3 }} />
|
||||
</div>
|
||||
</td>
|
||||
{Array.from({ length: SKELETON_COLS }).map((_, colIdx) => (
|
||||
<td key={colIdx} style={{ padding: 4, borderBottom: "1px solid var(--color-border)", borderLeft: "1px solid var(--color-border)", height: 56 }}>
|
||||
{(rowIdx + colIdx) % 3 === 0 && (
|
||||
<div className="skeleton" style={{ width: "100%", height: "100%", borderRadius: 6 }} />
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
149
app/page.tsx
149
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 (
|
||||
<div style={{ minHeight: "100vh", background: "var(--color-bg)" }}>
|
||||
{/* Header */}
|
||||
{/* ── Header ─────────────────────────────────────────────────────────── */}
|
||||
<header style={{
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
padding: "16px 24px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 16,
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
zIndex: 20,
|
||||
background: "var(--color-bg)",
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
}}>
|
||||
<div style={{ display: "flex", alignItems: "baseline", gap: 10 }}>
|
||||
<span style={{ fontSize: "1rem", fontWeight: 700, color: "var(--color-text)", letterSpacing: "-0.02em" }}>
|
||||
{/* Row 1: Title + park count */}
|
||||
<div style={{
|
||||
padding: "12px 24px 10px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 12,
|
||||
}}>
|
||||
<span style={{
|
||||
fontSize: "1.1rem",
|
||||
fontWeight: 700,
|
||||
color: "var(--color-text)",
|
||||
letterSpacing: "-0.02em",
|
||||
}}>
|
||||
Six Flags Calendar
|
||||
</span>
|
||||
<span style={{ fontSize: "0.75rem", color: "var(--color-text-muted)" }}>
|
||||
|
||||
<span style={{
|
||||
background: "var(--color-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
borderRadius: 20,
|
||||
padding: "3px 10px",
|
||||
fontSize: "0.7rem",
|
||||
color: "var(--color-text-muted)",
|
||||
fontWeight: 500,
|
||||
}}>
|
||||
{visibleParks.length} of {PARKS.length} parks open
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<WeekNav weekStart={weekStart} weekDates={weekDates} />
|
||||
|
||||
<Legend />
|
||||
{/* Row 2: Week nav + legend */}
|
||||
<div style={{
|
||||
padding: "8px 24px 10px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 16,
|
||||
borderTop: "1px solid var(--color-border-subtle)",
|
||||
}}>
|
||||
<WeekNav
|
||||
weekStart={weekStart}
|
||||
weekDates={weekDates}
|
||||
isCurrentWeek={isCurrentWeek}
|
||||
/>
|
||||
<Legend />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Calendar */}
|
||||
<main style={{ padding: "0 24px 40px" }}>
|
||||
{/* ── Main content ───────────────────────────────────────────────────── */}
|
||||
<main style={{ padding: "0 24px 48px" }}>
|
||||
{scrapedCount === 0 ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
<WeekCalendar parks={visibleParks} weekDates={weekDates} data={data} />
|
||||
<>
|
||||
{/* Mobile: card list (hidden on lg+) */}
|
||||
<div className="lg:hidden">
|
||||
<MobileCardList
|
||||
grouped={grouped}
|
||||
weekDates={weekDates}
|
||||
data={data}
|
||||
today={today}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Desktop: week table (hidden below lg) */}
|
||||
<div className="hidden lg:block">
|
||||
<WeekCalendar
|
||||
parks={visibleParks}
|
||||
weekDates={weekDates}
|
||||
data={data}
|
||||
grouped={grouped}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Legend() {
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 16, fontSize: "0.72rem", color: "var(--color-text-muted)" }}>
|
||||
<span style={{ display: "flex", alignItems: "center", gap: 5 }}>
|
||||
<span style={{
|
||||
display: "inline-block", width: 28, height: 14, borderRadius: 3,
|
||||
background: "var(--color-open-bg)", border: "1px solid var(--color-open-border)",
|
||||
}} />
|
||||
Open
|
||||
</span>
|
||||
<span style={{ display: "flex", alignItems: "center", gap: 5 }}>
|
||||
<span style={{ color: "var(--color-text-dim)" }}>Closed</span>
|
||||
<span style={{ color: "var(--color-border)" }}>·</span>
|
||||
<span style={{ color: "var(--color-text-dim)" }}>— no data</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState() {
|
||||
return (
|
||||
<div style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "80px 24px",
|
||||
gap: 12,
|
||||
color: "var(--color-text-muted)",
|
||||
}}>
|
||||
<div style={{ fontSize: "2rem" }}>📅</div>
|
||||
<div style={{ fontSize: "1rem", fontWeight: 600, color: "var(--color-text)" }}>No data scraped yet</div>
|
||||
<div style={{ fontSize: "0.85rem", textAlign: "center", lineHeight: 1.6 }}>
|
||||
Run the following to populate the calendar:
|
||||
</div>
|
||||
<pre style={{
|
||||
background: "var(--color-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
borderRadius: 8,
|
||||
padding: "12px 20px",
|
||||
fontSize: "0.8rem",
|
||||
color: "var(--color-text)",
|
||||
lineHeight: 1.8,
|
||||
}}>
|
||||
npm run discover{"\n"}npm run scrape
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
309
app/park/[id]/page.tsx
Normal file
309
app/park/[id]/page.tsx
Normal file
@@ -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 (
|
||||
<div style={{ minHeight: "100vh", background: "var(--color-bg)" }}>
|
||||
{/* ── Header ─────────────────────────────────────────────────────────── */}
|
||||
<header style={{
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
zIndex: 20,
|
||||
background: "var(--color-bg)",
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
padding: "12px 24px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 16,
|
||||
}}>
|
||||
<Link href="/" style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
fontSize: "0.8rem",
|
||||
color: "var(--color-text-muted)",
|
||||
textDecoration: "none",
|
||||
transition: "color 120ms ease",
|
||||
}}
|
||||
className="park-name-link"
|
||||
>
|
||||
← Calendar
|
||||
</Link>
|
||||
<div style={{ width: 1, height: 16, background: "var(--color-border)" }} />
|
||||
<span style={{ fontSize: "0.9rem", fontWeight: 600, color: "var(--color-text)", letterSpacing: "-0.01em" }}>
|
||||
{park.name}
|
||||
</span>
|
||||
<span style={{ fontSize: "0.75rem", color: "var(--color-text-muted)" }}>
|
||||
{park.location.city}, {park.location.state}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<main style={{ padding: "24px", maxWidth: 960, margin: "0 auto", display: "flex", flexDirection: "column", gap: 40 }}>
|
||||
|
||||
{/* ── Month Calendar ───────────────────────────────────────────────── */}
|
||||
<section>
|
||||
<ParkMonthCalendar
|
||||
parkId={id}
|
||||
year={year}
|
||||
month={month}
|
||||
monthData={monthData}
|
||||
today={today}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* ── Ride Status ─────────────────────────────────────────────────── */}
|
||||
<section>
|
||||
<SectionHeading>
|
||||
Rides
|
||||
<span style={{ fontSize: "0.72rem", fontWeight: 400, color: "var(--color-text-muted)", marginLeft: 8 }}>
|
||||
{ridesResult && !ridesResult.isExact
|
||||
? formatShortDate(ridesResult.dataDate)
|
||||
: "Today"}
|
||||
</span>
|
||||
</SectionHeading>
|
||||
|
||||
<RideList
|
||||
ridesResult={ridesResult}
|
||||
parkOpenToday={!!parkOpenToday}
|
||||
apiIdMissing={apiId === null}
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<div style={{
|
||||
display: "flex",
|
||||
alignItems: "baseline",
|
||||
gap: 0,
|
||||
marginBottom: 14,
|
||||
paddingBottom: 10,
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
}}>
|
||||
<h2 style={{
|
||||
fontSize: "0.85rem",
|
||||
fontWeight: 700,
|
||||
color: "var(--color-text)",
|
||||
letterSpacing: "0.04em",
|
||||
textTransform: "uppercase",
|
||||
margin: 0,
|
||||
}}>
|
||||
{children}
|
||||
</h2>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RideList({
|
||||
ridesResult,
|
||||
parkOpenToday,
|
||||
apiIdMissing,
|
||||
}: {
|
||||
ridesResult: RidesFetchResult | null;
|
||||
parkOpenToday: boolean;
|
||||
apiIdMissing: boolean;
|
||||
}) {
|
||||
if (apiIdMissing) {
|
||||
return (
|
||||
<Callout>
|
||||
Park API ID not discovered yet. Run{" "}
|
||||
<code style={{ background: "var(--color-surface-2)", padding: "1px 5px", borderRadius: 3, fontSize: "0.8em" }}>
|
||||
npm run discover
|
||||
</code>{" "}
|
||||
to enable ride data.
|
||||
</Callout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!parkOpenToday) {
|
||||
return <Callout>Park is closed today — no ride schedule available.</Callout>;
|
||||
}
|
||||
|
||||
if (!ridesResult || ridesResult.rides.length === 0) {
|
||||
return <Callout>Ride schedule is not yet available from the API.</Callout>;
|
||||
}
|
||||
|
||||
const { rides, isExact, dataDate, parkHoursLabel } = ridesResult;
|
||||
const openRides = rides.filter((r) => r.isOpen);
|
||||
const closedRides = rides.filter((r) => !r.isOpen);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Summary badge row */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 16, flexWrap: "wrap" }}>
|
||||
<div style={{
|
||||
background: "var(--color-open-bg)",
|
||||
border: "1px solid var(--color-open-border)",
|
||||
borderRadius: 20,
|
||||
padding: "4px 12px",
|
||||
fontSize: "0.72rem",
|
||||
fontWeight: 600,
|
||||
color: "var(--color-open-hours)",
|
||||
}}>
|
||||
{openRides.length} open
|
||||
</div>
|
||||
{closedRides.length > 0 && (
|
||||
<div style={{
|
||||
background: "var(--color-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
borderRadius: 20,
|
||||
padding: "4px 12px",
|
||||
fontSize: "0.72rem",
|
||||
fontWeight: 500,
|
||||
color: "var(--color-text-muted)",
|
||||
}}>
|
||||
{closedRides.length} closed / unscheduled
|
||||
</div>
|
||||
)}
|
||||
{!isExact && (
|
||||
<span style={{ fontSize: "0.7rem", color: "var(--color-text-dim)" }}>
|
||||
Showing {formatShortDate(dataDate)} — live schedule updates daily
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Two-column grid */}
|
||||
<div style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))",
|
||||
gap: 6,
|
||||
}}>
|
||||
{openRides.map((ride) => <RideRow key={ride.name} ride={ride} parkHoursLabel={parkHoursLabel} />)}
|
||||
{closedRides.map((ride) => <RideRow key={ride.name} ride={ride} parkHoursLabel={parkHoursLabel} />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 10,
|
||||
padding: "8px 12px",
|
||||
background: "var(--color-surface)",
|
||||
border: `1px solid ${ride.isOpen ? "var(--color-open-border)" : "var(--color-border)"}`,
|
||||
borderRadius: 8,
|
||||
opacity: ride.isOpen ? 1 : 0.6,
|
||||
}}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}>
|
||||
<span style={{
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: "50%",
|
||||
background: ride.isOpen ? "var(--color-open-text)" : "var(--color-text-dim)",
|
||||
flexShrink: 0,
|
||||
}} />
|
||||
<span style={{
|
||||
fontSize: "0.8rem",
|
||||
color: ride.isOpen ? "var(--color-text)" : "var(--color-text-muted)",
|
||||
fontWeight: ride.isOpen ? 500 : 400,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}>
|
||||
{ride.name}
|
||||
</span>
|
||||
</div>
|
||||
{showHours && (
|
||||
<span style={{
|
||||
fontSize: "0.72rem",
|
||||
color: "var(--color-open-hours)",
|
||||
fontWeight: 500,
|
||||
flexShrink: 0,
|
||||
whiteSpace: "nowrap",
|
||||
}}>
|
||||
{ride.hoursLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Callout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{
|
||||
padding: "14px 18px",
|
||||
background: "var(--color-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
borderRadius: 8,
|
||||
fontSize: "0.82rem",
|
||||
color: "var(--color-text-muted)",
|
||||
lineHeight: 1.5,
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
import type { Park } from "@/lib/scrapers/types";
|
||||
|
||||
interface CalendarGridProps {
|
||||
parks: Park[];
|
||||
calendar: Record<string, boolean[]>;
|
||||
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 (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="border-collapse text-sm w-full min-w-max">
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
className="sticky left-0 z-10 px-3 py-2 text-left font-medium border-b min-w-40"
|
||||
style={{
|
||||
backgroundColor: "var(--color-bg)",
|
||||
color: "var(--color-text-muted)",
|
||||
borderColor: "var(--color-border)",
|
||||
}}
|
||||
>
|
||||
Park
|
||||
</th>
|
||||
{days.map((day) => {
|
||||
const dow = new Date(year, month - 1, day).getDay();
|
||||
const isWeekend = dow === 0 || dow === 6;
|
||||
const isToday = day === todayDay;
|
||||
return (
|
||||
<th
|
||||
key={day}
|
||||
className="px-1 py-2 text-center font-normal w-8 border-b"
|
||||
style={{
|
||||
color: isWeekend
|
||||
? "var(--color-text)"
|
||||
: "var(--color-text-muted)",
|
||||
borderColor: "var(--color-border)",
|
||||
backgroundColor: isToday
|
||||
? "var(--color-surface)"
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<div className="text-xs">{DOW_LABELS[dow]}</div>
|
||||
<div
|
||||
style={
|
||||
isToday
|
||||
? { fontWeight: 700, color: "var(--color-open)" }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{day}
|
||||
</div>
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{parks.map((park) => {
|
||||
const parkDays = calendar[park.id] ?? [];
|
||||
return (
|
||||
<tr key={park.id}>
|
||||
<td
|
||||
className="sticky left-0 z-10 px-3 py-1 text-xs border-b whitespace-nowrap"
|
||||
style={{
|
||||
backgroundColor: "var(--color-bg)",
|
||||
color: "var(--color-text-muted)",
|
||||
borderColor: "var(--color-border)",
|
||||
}}
|
||||
>
|
||||
{park.shortName}
|
||||
</td>
|
||||
{days.map((day) => {
|
||||
const isOpen = parkDays[day - 1] ?? false;
|
||||
const isToday = day === todayDay;
|
||||
return (
|
||||
<td
|
||||
key={day}
|
||||
className="px-1 py-1 text-center border-b"
|
||||
style={{
|
||||
borderColor: "var(--color-border)",
|
||||
backgroundColor: isToday
|
||||
? "rgba(30,41,59,0.3)"
|
||||
: undefined,
|
||||
}}
|
||||
title={`${park.shortName} — ${month}/${day}: ${isOpen ? "Open" : "Closed"}`}
|
||||
>
|
||||
<span
|
||||
className="inline-block w-5 h-5 rounded-sm"
|
||||
style={{
|
||||
backgroundColor: isOpen
|
||||
? "var(--color-open)"
|
||||
: "var(--color-closed)",
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
components/EmptyState.tsx
Normal file
30
components/EmptyState.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
export function EmptyState() {
|
||||
return (
|
||||
<div style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "80px 24px",
|
||||
gap: 12,
|
||||
color: "var(--color-text-muted)",
|
||||
}}>
|
||||
<div style={{ fontSize: "2rem" }}>📅</div>
|
||||
<div style={{ fontSize: "1rem", fontWeight: 600, color: "var(--color-text)" }}>No data scraped yet</div>
|
||||
<div style={{ fontSize: "0.85rem", textAlign: "center", lineHeight: 1.6 }}>
|
||||
Run the following to populate the calendar:
|
||||
</div>
|
||||
<pre style={{
|
||||
background: "var(--color-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
borderRadius: 8,
|
||||
padding: "12px 20px",
|
||||
fontSize: "0.8rem",
|
||||
color: "var(--color-text)",
|
||||
lineHeight: 1.8,
|
||||
}}>
|
||||
npm run discover{"\n"}npm run scrape
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
components/Legend.tsx
Normal file
33
components/Legend.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
export function Legend() {
|
||||
return (
|
||||
<div style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 16,
|
||||
fontSize: "0.72rem",
|
||||
color: "var(--color-text-muted)",
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<span style={{ display: "flex", alignItems: "center", gap: 5 }}>
|
||||
<span style={{
|
||||
display: "inline-block", width: 28, height: 14, borderRadius: 3,
|
||||
background: "var(--color-open-bg)", border: "1px solid var(--color-open-border)",
|
||||
}} />
|
||||
Open
|
||||
</span>
|
||||
<span style={{ display: "flex", alignItems: "center", gap: 5 }}>
|
||||
<span style={{
|
||||
display: "inline-block", width: 28, height: 14, borderRadius: 3,
|
||||
background: "var(--color-ph-bg)", border: "1px solid var(--color-ph-border)",
|
||||
}} />
|
||||
Passholder
|
||||
</span>
|
||||
<span style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||
<span style={{ color: "var(--color-text-dim)" }}>·</span>
|
||||
<span style={{ color: "var(--color-text-dim)" }}>Closed</span>
|
||||
<span style={{ color: "var(--color-border)" }}>·</span>
|
||||
<span style={{ color: "var(--color-text-dim)" }}>— no data</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
components/MobileCardList.tsx
Normal file
60
components/MobileCardList.tsx
Normal file
@@ -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<Region, Park[]>;
|
||||
weekDates: string[];
|
||||
data: Record<string, Record<string, DayData>>;
|
||||
today: string;
|
||||
}
|
||||
|
||||
export function MobileCardList({ grouped, weekDates, data, today }: MobileCardListProps) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 24, paddingTop: 16 }}>
|
||||
{Array.from(grouped.entries()).map(([region, parks]) => (
|
||||
<div key={region} data-region={region}>
|
||||
{/* Region heading */}
|
||||
<div style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
marginBottom: 10,
|
||||
paddingLeft: 2,
|
||||
}}>
|
||||
<div style={{
|
||||
width: 3,
|
||||
height: 14,
|
||||
borderRadius: 2,
|
||||
background: "var(--color-region-accent)",
|
||||
flexShrink: 0,
|
||||
}} />
|
||||
<span style={{
|
||||
fontSize: "0.65rem",
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.1em",
|
||||
textTransform: "uppercase",
|
||||
color: "var(--color-text-muted)",
|
||||
}}>
|
||||
{region}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Park cards */}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{parks.map((park) => (
|
||||
<ParkCard
|
||||
key={park.id}
|
||||
park={park}
|
||||
weekDates={weekDates}
|
||||
parkData={data[park.id] ?? {}}
|
||||
today={today}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "16px" }}>
|
||||
<button onClick={() => navigate(-1)} style={btnStyle} aria-label="Previous month">
|
||||
←
|
||||
</button>
|
||||
<h1 style={{ fontSize: "1.25rem", fontWeight: 600, color: "var(--color-text)", margin: 0 }}>
|
||||
{MONTH_NAMES[currentMonth - 1]} {currentYear}
|
||||
</h1>
|
||||
<button onClick={() => navigate(1)} style={btnStyle} aria-label="Next month">
|
||||
→
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
170
components/ParkCard.tsx
Normal file
170
components/ParkCard.tsx
Normal file
@@ -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<string, DayData>;
|
||||
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 (
|
||||
<div
|
||||
data-park={park.name.toLowerCase()}
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
borderRadius: 12,
|
||||
padding: "14px 14px 12px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
{/* Park name + location */}
|
||||
<div>
|
||||
<Link href={`/park/${park.id}`} className="park-name-link">
|
||||
<span style={{ fontWeight: 600, fontSize: "0.9rem", lineHeight: 1.2 }}>
|
||||
{park.name}
|
||||
</span>
|
||||
</Link>
|
||||
<div style={{ fontSize: "0.7rem", color: "var(--color-text-muted)", marginTop: 2 }}>
|
||||
{park.location.city}, {park.location.state}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 7-day grid */}
|
||||
<div style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(7, 1fr)",
|
||||
gap: 4,
|
||||
}}>
|
||||
{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 (
|
||||
<div key={date} style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 3,
|
||||
padding: "6px 2px",
|
||||
background: cellBg,
|
||||
border: cellBorder,
|
||||
borderRadius: cellBorderRadius,
|
||||
minWidth: 0,
|
||||
}}>
|
||||
{/* Day name */}
|
||||
<span style={{
|
||||
fontSize: "0.6rem",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.04em",
|
||||
color: isToday ? "var(--color-today-text)" : pd.isWeekend ? "var(--color-text-secondary)" : "var(--color-text-muted)",
|
||||
fontWeight: isToday || pd.isWeekend ? 600 : 400,
|
||||
}}>
|
||||
{DOW_SHORT[pd.dow]}
|
||||
</span>
|
||||
|
||||
{/* Date number */}
|
||||
<span style={{
|
||||
fontSize: "0.8rem",
|
||||
fontWeight: isToday ? 700 : 500,
|
||||
color: isToday ? "var(--color-today-text)" : pd.isWeekend ? "var(--color-text)" : "var(--color-text-secondary)",
|
||||
lineHeight: 1,
|
||||
}}>
|
||||
{pd.day}
|
||||
</span>
|
||||
|
||||
{/* Status */}
|
||||
{!dayData ? (
|
||||
<span style={{ fontSize: "0.65rem", color: "var(--color-text-dim)", lineHeight: 1 }}>—</span>
|
||||
) : isPH && isOpen ? (
|
||||
<span style={{
|
||||
fontSize: "0.6rem",
|
||||
fontWeight: 700,
|
||||
color: "var(--color-ph-label)",
|
||||
letterSpacing: "0.02em",
|
||||
textAlign: "center",
|
||||
lineHeight: 1.2,
|
||||
}}>
|
||||
PH
|
||||
</span>
|
||||
) : isOpen ? (
|
||||
<span style={{
|
||||
fontSize: "0.58rem",
|
||||
fontWeight: 600,
|
||||
color: "var(--color-open-text)",
|
||||
lineHeight: 1,
|
||||
textAlign: "center",
|
||||
}}>
|
||||
Open
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ fontSize: "0.9rem", color: "var(--color-text-dim)", lineHeight: 1 }}>·</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Hours detail row — show the open day hours inline */}
|
||||
{weekDates.some((d) => parkData[d]?.isOpen && parkData[d]?.hoursLabel) && (
|
||||
<div style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: "4px 12px",
|
||||
paddingTop: 4,
|
||||
borderTop: "1px solid var(--color-border-subtle)",
|
||||
}}>
|
||||
{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 (
|
||||
<span key={date} style={{
|
||||
fontSize: "0.68rem",
|
||||
color: isPH ? "var(--color-ph-hours)" : "var(--color-open-hours)",
|
||||
display: "flex",
|
||||
gap: 4,
|
||||
alignItems: "center",
|
||||
}}>
|
||||
<span style={{ color: "var(--color-text-muted)", fontWeight: 600 }}>
|
||||
{DOW_SHORT[pd.dow]}
|
||||
</span>
|
||||
{dayData.hoursLabel}
|
||||
{isPH && (
|
||||
<span style={{ color: "var(--color-ph-label)", fontSize: "0.6rem", fontWeight: 700 }}>PH</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
226
components/ParkMonthCalendar.tsx
Normal file
226
components/ParkMonthCalendar.tsx
Normal file
@@ -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<string, DayData>; // '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 (
|
||||
<div>
|
||||
{/* Month nav */}
|
||||
<div style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 16,
|
||||
marginBottom: 16,
|
||||
}}>
|
||||
<Link
|
||||
href={`/park/${parkId}?month=${prevParam}`}
|
||||
style={navLinkStyle}
|
||||
>
|
||||
←
|
||||
</Link>
|
||||
<span style={{
|
||||
fontSize: "1rem",
|
||||
fontWeight: 600,
|
||||
color: "var(--color-text)",
|
||||
minWidth: 160,
|
||||
textAlign: "center",
|
||||
letterSpacing: "-0.01em",
|
||||
}}>
|
||||
{MONTH_NAMES[month - 1]} {year}
|
||||
</span>
|
||||
<Link
|
||||
href={`/park/${parkId}?month=${nextParam}`}
|
||||
style={navLinkStyle}
|
||||
>
|
||||
→
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Calendar grid */}
|
||||
<div style={{
|
||||
border: "1px solid var(--color-border)",
|
||||
borderRadius: 10,
|
||||
overflow: "hidden",
|
||||
background: "var(--color-surface)",
|
||||
}}>
|
||||
{/* DOW header */}
|
||||
<div style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(7, 1fr)",
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
}}>
|
||||
{DOW_LABELS.map((d, i) => (
|
||||
<div key={d} style={{
|
||||
padding: "8px 0",
|
||||
textAlign: "center",
|
||||
fontSize: "0.65rem",
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.06em",
|
||||
textTransform: "uppercase",
|
||||
color: i === 0 || i === 6 ? "var(--color-text-secondary)" : "var(--color-text-muted)",
|
||||
background: i === 0 || i === 6 ? "var(--color-weekend-header)" : "transparent",
|
||||
}}>
|
||||
{d}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Weeks */}
|
||||
{weeks.map((week, wi) => (
|
||||
<div key={wi} style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(7, 1fr)",
|
||||
borderBottom: wi < weeks.length - 1 ? "1px solid var(--color-border-subtle)" : "none",
|
||||
}}>
|
||||
{week.map((cell, ci) => {
|
||||
if (!cell.day || !cell.iso) {
|
||||
return (
|
||||
<div key={ci} style={{
|
||||
minHeight: 72,
|
||||
background: ci === 0 || ci === 6 ? "var(--color-weekend-header)" : "transparent",
|
||||
borderRight: ci < 6 ? "1px solid var(--color-border-subtle)" : "none",
|
||||
}} />
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div key={ci} style={{
|
||||
minHeight: 72,
|
||||
padding: "8px 10px",
|
||||
background: bg,
|
||||
borderRight: ci < 6 ? "1px solid var(--color-border-subtle)" : "none",
|
||||
borderLeft: isToday ? "2px solid var(--color-today-border)" : "none",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 4,
|
||||
}}>
|
||||
{/* Date number */}
|
||||
<span style={{
|
||||
fontSize: "0.85rem",
|
||||
fontWeight: isToday ? 700 : isWeekend ? 600 : 400,
|
||||
color: isToday
|
||||
? "var(--color-today-text)"
|
||||
: isWeekend
|
||||
? "var(--color-text)"
|
||||
: "var(--color-text-muted)",
|
||||
lineHeight: 1,
|
||||
}}>
|
||||
{cell.day}
|
||||
</span>
|
||||
|
||||
{/* Status */}
|
||||
{!dayData ? (
|
||||
<span style={{ fontSize: "0.65rem", color: "var(--color-text-dim)" }}>—</span>
|
||||
) : isPH && isOpen ? (
|
||||
<div style={{
|
||||
background: "var(--color-ph-bg)",
|
||||
border: "1px solid var(--color-ph-border)",
|
||||
borderRadius: 4,
|
||||
padding: "2px 5px",
|
||||
}}>
|
||||
<div style={{ fontSize: "0.55rem", fontWeight: 700, color: "var(--color-ph-label)", textTransform: "uppercase", letterSpacing: "0.05em" }}>
|
||||
Passholder
|
||||
</div>
|
||||
<div style={{ fontSize: "0.6rem", color: "var(--color-ph-hours)", marginTop: 1 }}>
|
||||
{dayData.hoursLabel}
|
||||
</div>
|
||||
</div>
|
||||
) : isOpen ? (
|
||||
<div style={{
|
||||
background: "var(--color-open-bg)",
|
||||
border: "1px solid var(--color-open-border)",
|
||||
borderRadius: 4,
|
||||
padding: "2px 5px",
|
||||
}}>
|
||||
<div style={{ fontSize: "0.6rem", color: "var(--color-open-hours)" }}>
|
||||
{dayData.hoursLabel}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ fontSize: "0.9rem", color: "var(--color-text-dim)", lineHeight: 1 }}>·</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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",
|
||||
};
|
||||
@@ -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<string, Record<string, DayData>>; // parkId → date → DayData
|
||||
grouped?: Map<Region, Park[]>; // 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 (
|
||||
<td style={base}>
|
||||
<span style={{ color: "var(--color-text-dim)", fontSize: "0.8rem" }}>—</span>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
if (!dayData.isOpen || !dayData.hoursLabel) {
|
||||
return (
|
||||
<td style={base}>
|
||||
<span style={{ color: "var(--color-text-dim)", fontSize: "1rem", lineHeight: 1 }}>·</span>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
if (dayData.specialType === "passholder_preview") {
|
||||
return (
|
||||
<td style={{ ...base, padding: 4 }}>
|
||||
<div style={{
|
||||
background: "var(--color-ph-bg)",
|
||||
border: "1px solid var(--color-ph-border)",
|
||||
borderRadius: 6,
|
||||
padding: "4px 4px",
|
||||
textAlign: "center",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 2,
|
||||
transition: "filter 150ms ease",
|
||||
}}>
|
||||
<span style={{
|
||||
color: "var(--color-ph-label)",
|
||||
fontSize: "0.58rem",
|
||||
fontWeight: 600,
|
||||
letterSpacing: "0.05em",
|
||||
textTransform: "uppercase",
|
||||
whiteSpace: "nowrap",
|
||||
}}>
|
||||
Passholder
|
||||
</span>
|
||||
<span style={{
|
||||
color: "var(--color-ph-hours)",
|
||||
fontSize: "0.7rem",
|
||||
fontWeight: 500,
|
||||
letterSpacing: "-0.01em",
|
||||
whiteSpace: "nowrap",
|
||||
}}>
|
||||
{dayData.hoursLabel}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<td style={{ ...base, padding: 4 }}>
|
||||
<div style={{
|
||||
background: "var(--color-open-bg)",
|
||||
border: "1px solid var(--color-open-border)",
|
||||
borderRadius: 6,
|
||||
padding: "6px 4px",
|
||||
textAlign: "center",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
transition: "filter 150ms ease",
|
||||
}}>
|
||||
<span style={{
|
||||
color: "var(--color-open-hours)",
|
||||
fontSize: "0.7rem",
|
||||
fontWeight: 500,
|
||||
letterSpacing: "-0.01em",
|
||||
whiteSpace: "nowrap",
|
||||
}}>
|
||||
{dayData.hoursLabel}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
function RegionHeader({ region, colSpan }: { region: string; colSpan: number }) {
|
||||
return (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={colSpan}
|
||||
style={{
|
||||
padding: "10px 14px 6px",
|
||||
background: "var(--color-region-bg)",
|
||||
borderBottom: "1px solid var(--color-border-subtle)",
|
||||
borderLeft: "3px solid var(--color-region-accent)",
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
fontSize: "0.65rem",
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.1em",
|
||||
textTransform: "uppercase",
|
||||
color: "var(--color-text-muted)",
|
||||
}}>
|
||||
{region}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function ParkRow({
|
||||
park,
|
||||
parkIdx,
|
||||
weekDates,
|
||||
parsedDates,
|
||||
parkData,
|
||||
today,
|
||||
}: {
|
||||
park: Park;
|
||||
parkIdx: number;
|
||||
weekDates: string[];
|
||||
parsedDates: ReturnType<typeof parseDate>[];
|
||||
parkData: Record<string, DayData>;
|
||||
today: string;
|
||||
}) {
|
||||
const rowBg = parkIdx % 2 === 0 ? "var(--color-bg)" : "var(--color-surface)";
|
||||
return (
|
||||
<tr
|
||||
className="park-row"
|
||||
data-park={park.name.toLowerCase()}
|
||||
style={{ background: rowBg }}
|
||||
>
|
||||
<td className="sticky-shadow" style={{
|
||||
position: "sticky",
|
||||
left: 0,
|
||||
zIndex: 5,
|
||||
padding: "10px 14px",
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
borderRight: "1px solid var(--color-border)",
|
||||
whiteSpace: "nowrap",
|
||||
verticalAlign: "middle",
|
||||
background: rowBg,
|
||||
transition: "background 120ms ease",
|
||||
}}>
|
||||
<Link href={`/park/${park.id}`} className="park-name-link">
|
||||
<span style={{ fontWeight: 500, fontSize: "0.85rem", lineHeight: 1.2 }}>
|
||||
{park.name}
|
||||
</span>
|
||||
</Link>
|
||||
<div style={{ fontSize: "0.7rem", color: "var(--color-text-muted)", marginTop: 2 }}>
|
||||
{park.location.city}, {park.location.state}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{weekDates.map((date, i) => (
|
||||
<DayCell
|
||||
key={date}
|
||||
date={date}
|
||||
dayData={parkData[date]}
|
||||
isToday={date === today}
|
||||
isWeekend={parsedDates[i].isWeekend}
|
||||
/>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div style={{ overflowX: "auto", overflowY: "visible" }}>
|
||||
<table style={{
|
||||
@@ -41,21 +247,19 @@ export function WeekCalendar({ parks, weekDates, data }: WeekCalendarProps) {
|
||||
tableLayout: "fixed",
|
||||
}}>
|
||||
<colgroup>
|
||||
{/* Park name column */}
|
||||
<col style={{ width: 220 }} />
|
||||
<col style={{ width: 240 }} />
|
||||
{weekDates.map((d) => (
|
||||
<col key={d} style={{ width: 120 }} />
|
||||
<col key={d} style={{ width: 130 }} />
|
||||
))}
|
||||
</colgroup>
|
||||
|
||||
<thead>
|
||||
{/* 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) && (
|
||||
<tr>
|
||||
<th style={thParkStyle} />
|
||||
{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 (
|
||||
<th key={date} style={{
|
||||
@@ -63,7 +267,7 @@ export function WeekCalendar({ parks, weekDates, data }: WeekCalendarProps) {
|
||||
background: pd.isWeekend ? "var(--color-weekend-header)" : "var(--color-bg)",
|
||||
paddingBottom: 0,
|
||||
paddingTop: 8,
|
||||
fontSize: "0.7rem",
|
||||
fontSize: "0.65rem",
|
||||
color: "var(--color-text-muted)",
|
||||
letterSpacing: "0.06em",
|
||||
textTransform: "uppercase",
|
||||
@@ -79,7 +283,7 @@ export function WeekCalendar({ parks, weekDates, data }: WeekCalendarProps) {
|
||||
{/* Day header */}
|
||||
<tr>
|
||||
<th style={thParkStyle}>
|
||||
<span style={{ color: "var(--color-text-muted)", fontSize: "0.7rem", letterSpacing: "0.08em", textTransform: "uppercase" }}>
|
||||
<span style={{ color: "var(--color-text-muted)", fontSize: "0.65rem", letterSpacing: "0.08em", textTransform: "uppercase" }}>
|
||||
Park
|
||||
</span>
|
||||
</th>
|
||||
@@ -99,16 +303,16 @@ export function WeekCalendar({ parks, weekDates, data }: WeekCalendarProps) {
|
||||
: `1px solid var(--color-border)`,
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: "0.7rem",
|
||||
fontSize: "0.65rem",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.06em",
|
||||
color: isToday ? "var(--color-today-text)" : pd.isWeekend ? "var(--color-text)" : "var(--color-text-muted)",
|
||||
color: isToday ? "var(--color-today-text)" : pd.isWeekend ? "var(--color-text-secondary)" : "var(--color-text-muted)",
|
||||
marginBottom: 2,
|
||||
}}>
|
||||
{DOW[pd.dow]}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: "1.05rem",
|
||||
fontSize: "1rem",
|
||||
fontWeight: isToday ? 700 : pd.isWeekend ? 600 : 400,
|
||||
color: isToday ? "var(--color-today-text)" : pd.isWeekend ? "var(--color-text)" : "var(--color-text-muted)",
|
||||
}}>
|
||||
@@ -121,143 +325,24 @@ export function WeekCalendar({ parks, weekDates, data }: WeekCalendarProps) {
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{parks.map((park, parkIdx) => {
|
||||
const parkData = data[park.id] ?? {};
|
||||
return (
|
||||
<tr
|
||||
key={park.id}
|
||||
style={{
|
||||
background: parkIdx % 2 === 0 ? "var(--color-bg)" : "var(--color-surface)",
|
||||
}}
|
||||
>
|
||||
{/* Park name */}
|
||||
<td style={{
|
||||
...tdParkStyle,
|
||||
background: parkIdx % 2 === 0 ? "var(--color-bg)" : "var(--color-surface)",
|
||||
}}>
|
||||
<div style={{ fontWeight: 500, color: "var(--color-text)", fontSize: "0.8rem", lineHeight: 1.2 }}>
|
||||
{park.name}
|
||||
</div>
|
||||
<div style={{ fontSize: "0.65rem", color: "var(--color-text-muted)", marginTop: 2 }}>
|
||||
{park.location.city}, {park.location.state}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* 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 (
|
||||
<td key={date} style={{
|
||||
...tdBase,
|
||||
background: isToday ? "var(--color-today-bg)" : pd.isWeekend ? "var(--color-weekend-header)" : "transparent",
|
||||
borderLeft: isToday ? "1px solid var(--color-today-border)" : undefined,
|
||||
borderRight: isToday ? "1px solid var(--color-today-border)" : undefined,
|
||||
}}>
|
||||
<span style={{ color: "var(--color-text-dim)", fontSize: "0.65rem" }}>—</span>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
// Treat open-but-no-hours the same as closed (stale data or missing hours)
|
||||
if (!dayData.isOpen || !dayData.hoursLabel) {
|
||||
return (
|
||||
<td key={date} style={{
|
||||
...tdBase,
|
||||
background: isToday ? "var(--color-today-bg)" : pd.isWeekend ? "var(--color-weekend-header)" : "transparent",
|
||||
borderLeft: isToday ? "1px solid var(--color-today-border)" : undefined,
|
||||
borderRight: isToday ? "1px solid var(--color-today-border)" : undefined,
|
||||
}}>
|
||||
<span style={{ color: "var(--color-text-dim)", fontSize: "0.65rem" }}>Closed</span>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
// Passholder preview day
|
||||
if (dayData.specialType === "passholder_preview") {
|
||||
return (
|
||||
<td key={date} style={{
|
||||
...tdBase,
|
||||
padding: 4,
|
||||
borderLeft: isToday ? "1px solid var(--color-today-border)" : undefined,
|
||||
borderRight: isToday ? "1px solid var(--color-today-border)" : undefined,
|
||||
}}>
|
||||
<div style={{
|
||||
background: "var(--color-ph-bg)",
|
||||
border: "1px solid var(--color-ph-border)",
|
||||
borderRadius: 6,
|
||||
padding: "4px 4px",
|
||||
textAlign: "center",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 2,
|
||||
}}>
|
||||
<span style={{
|
||||
color: "var(--color-ph-label)",
|
||||
fontSize: "0.6rem",
|
||||
fontWeight: 600,
|
||||
letterSpacing: "0.04em",
|
||||
textTransform: "uppercase",
|
||||
whiteSpace: "nowrap",
|
||||
}}>
|
||||
Passholder
|
||||
</span>
|
||||
<span style={{
|
||||
color: "var(--color-ph-hours)",
|
||||
fontSize: "0.72rem",
|
||||
fontWeight: 500,
|
||||
letterSpacing: "-0.01em",
|
||||
whiteSpace: "nowrap",
|
||||
}}>
|
||||
{dayData.hoursLabel}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
// Open with confirmed hours
|
||||
return (
|
||||
<td key={date} style={{
|
||||
...tdBase,
|
||||
padding: 4,
|
||||
borderLeft: isToday ? "1px solid var(--color-today-border)" : undefined,
|
||||
borderRight: isToday ? "1px solid var(--color-today-border)" : undefined,
|
||||
}}>
|
||||
<div style={{
|
||||
background: "var(--color-open-bg)",
|
||||
border: "1px solid var(--color-open-border)",
|
||||
borderRadius: 6,
|
||||
padding: "6px 4px",
|
||||
textAlign: "center",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}>
|
||||
<span style={{
|
||||
color: "var(--color-open-hours)",
|
||||
fontSize: "0.72rem",
|
||||
fontWeight: 500,
|
||||
letterSpacing: "-0.01em",
|
||||
whiteSpace: "nowrap",
|
||||
}}>
|
||||
{dayData.hoursLabel}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{sections.map(({ region, parks: sectionParks }) => (
|
||||
<Fragment key={region ?? "__all__"}>
|
||||
{region && (
|
||||
<RegionHeader region={region} colSpan={colSpan} />
|
||||
)}
|
||||
{sectionParks.map((park, parkIdx) => (
|
||||
<ParkRow
|
||||
key={park.id}
|
||||
park={park}
|
||||
parkIdx={parkIdx}
|
||||
weekDates={weekDates}
|
||||
parsedDates={parsedDates}
|
||||
parkData={data[park.id] ?? {}}
|
||||
today={today}
|
||||
/>
|
||||
))}
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<button
|
||||
onClick={() => 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)}
|
||||
>
|
||||
←
|
||||
</button>
|
||||
|
||||
{!isCurrentWeek && (
|
||||
<button
|
||||
onClick={() => 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
|
||||
</button>
|
||||
)}
|
||||
|
||||
<span style={{
|
||||
fontSize: "1.1rem",
|
||||
fontSize: "1rem",
|
||||
fontWeight: 600,
|
||||
color: "var(--color-text)",
|
||||
minWidth: 220,
|
||||
minWidth: 200,
|
||||
textAlign: "center",
|
||||
letterSpacing: "-0.01em",
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
}}>
|
||||
{formatLabel(weekDates)}
|
||||
</span>
|
||||
@@ -57,7 +74,9 @@ export function WeekNav({ weekStart, weekDates }: WeekNavProps) {
|
||||
<button
|
||||
onClick={() => 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)}
|
||||
>
|
||||
→
|
||||
</button>
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
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