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