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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user