feat: UI redesign with park detail pages and ride status
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:
2026-04-04 11:53:06 -04:00
parent 5f82407fea
commit e48038c399
17 changed files with 1605 additions and 442 deletions

View File

@@ -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
View 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
View 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>
);
}

View 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>
);
}

View File

@@ -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
View 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>
);
}

View 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",
};

View File

@@ -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, SunSat
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,
};

View File

@@ -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",
};