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