All checks were successful
Build and Deploy / Build & Push (push) Successful in 53s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
427 lines
14 KiB
TypeScript
427 lines
14 KiB
TypeScript
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";
|
||
import { getTodayLocal, getTimezoneAbbr } from "@/lib/env";
|
||
|
||
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)
|
||
rideCounts?: Record<string, number>; // parkId → open ride/coaster count for today
|
||
coastersOnly?: boolean;
|
||
openParkIds?: string[];
|
||
closingParkIds?: string[];
|
||
weatherDelayParkIds?: string[];
|
||
}
|
||
|
||
const DOW = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||
const MONTHS = [
|
||
"Jan","Feb","Mar","Apr","May","Jun",
|
||
"Jul","Aug","Sep","Oct","Nov","Dec",
|
||
];
|
||
|
||
function parseDate(iso: string) {
|
||
const d = new Date(iso + "T00:00:00");
|
||
return {
|
||
month: d.getMonth(),
|
||
day: d.getDate(),
|
||
dow: d.getDay(),
|
||
isWeekend: d.getDay() === 0 || d.getDay() === 6,
|
||
};
|
||
}
|
||
|
||
function DayCell({
|
||
dayData,
|
||
isWeekend,
|
||
tzAbbr,
|
||
}: {
|
||
dayData: DayData | undefined;
|
||
isWeekend: boolean;
|
||
tzAbbr: string;
|
||
}) {
|
||
const base: React.CSSProperties = {
|
||
padding: 0,
|
||
textAlign: "center",
|
||
verticalAlign: "middle",
|
||
borderBottom: "1px solid var(--color-border)",
|
||
borderLeft: "1px solid var(--color-border)",
|
||
height: 72,
|
||
background: isWeekend ? "var(--color-weekend-header)" : "transparent",
|
||
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: 6 }}>
|
||
<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.78rem",
|
||
fontWeight: 600,
|
||
letterSpacing: "-0.01em",
|
||
whiteSpace: "nowrap",
|
||
}}>
|
||
{dayData.hoursLabel}
|
||
</span>
|
||
<span style={{
|
||
color: "var(--color-ph-label)",
|
||
fontSize: "0.6rem",
|
||
fontWeight: 500,
|
||
letterSpacing: "0.04em",
|
||
}}>
|
||
{tzAbbr}
|
||
</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: "4px",
|
||
textAlign: "center",
|
||
height: "100%",
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
gap: 2,
|
||
transition: "filter 150ms ease",
|
||
}}>
|
||
<span style={{
|
||
color: "var(--color-open-hours)",
|
||
fontSize: "0.78rem",
|
||
fontWeight: 600,
|
||
letterSpacing: "-0.01em",
|
||
whiteSpace: "nowrap",
|
||
}}>
|
||
{dayData.hoursLabel}
|
||
</span>
|
||
<span style={{
|
||
color: "var(--color-open-hours)",
|
||
fontSize: "0.6rem",
|
||
fontWeight: 500,
|
||
opacity: 0.6,
|
||
letterSpacing: "0.04em",
|
||
}}>
|
||
{tzAbbr}
|
||
</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)",
|
||
}}
|
||
>
|
||
<span style={{
|
||
fontSize: "0.6rem",
|
||
fontWeight: 700,
|
||
letterSpacing: "0.14em",
|
||
textTransform: "uppercase",
|
||
color: "var(--color-text-secondary)",
|
||
}}>
|
||
— {region} —
|
||
</span>
|
||
</td>
|
||
</tr>
|
||
);
|
||
}
|
||
|
||
function ParkRow({
|
||
park,
|
||
parkIdx,
|
||
weekDates,
|
||
parsedDates,
|
||
parkData,
|
||
rideCounts,
|
||
coastersOnly,
|
||
openParkIds,
|
||
closingParkIds,
|
||
weatherDelayParkIds,
|
||
}: {
|
||
park: Park;
|
||
parkIdx: number;
|
||
weekDates: string[];
|
||
parsedDates: ReturnType<typeof parseDate>[];
|
||
parkData: Record<string, DayData>;
|
||
rideCounts?: Record<string, number>;
|
||
coastersOnly?: boolean;
|
||
openParkIds?: string[];
|
||
closingParkIds?: string[];
|
||
weatherDelayParkIds?: string[];
|
||
}) {
|
||
const rowBg = parkIdx % 2 === 0 ? "var(--color-bg)" : "var(--color-surface)";
|
||
const tzAbbr = getTimezoneAbbr(park.timezone);
|
||
const isOpen = openParkIds?.includes(park.id) ?? false;
|
||
const isClosing = closingParkIds?.includes(park.id) ?? false;
|
||
const isWeatherDelay = weatherDelayParkIds?.includes(park.id) ?? false;
|
||
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: 0,
|
||
borderBottom: "1px solid var(--color-border)",
|
||
borderRight: "1px solid var(--color-border)",
|
||
borderLeft: isOpen
|
||
? `3px solid ${isWeatherDelay ? "var(--color-weather-border)" : isClosing ? "var(--color-closing-border)" : "var(--color-open-border)"}`
|
||
: "3px solid transparent",
|
||
verticalAlign: "middle",
|
||
background: rowBg,
|
||
transition: "background 120ms ease",
|
||
}}>
|
||
<Link href={`/park/${park.id}`} className="park-name-link" style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "space-between",
|
||
padding: "10px 14px",
|
||
gap: 10,
|
||
}}>
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||
<span style={{ fontWeight: 500, fontSize: "0.85rem", lineHeight: 1.2, color: "var(--color-text)", whiteSpace: "nowrap" }}>
|
||
{park.name}
|
||
</span>
|
||
</div>
|
||
<div style={{ fontSize: "0.7rem", color: "var(--color-text-muted)", marginTop: 2 }}>
|
||
{park.location.city}, {park.location.state}
|
||
</div>
|
||
</div>
|
||
{isWeatherDelay && (
|
||
<div style={{ fontSize: "0.72rem", color: "var(--color-weather-text)", fontWeight: 600, textAlign: "center", maxWidth: 72, lineHeight: 1.3 }}>
|
||
Weather Delay
|
||
</div>
|
||
)}
|
||
{!isWeatherDelay && rideCounts?.[park.id] !== undefined && (
|
||
<div style={{ fontSize: "0.72rem", color: isClosing ? "var(--color-closing-hours)" : "var(--color-open-hours)", fontWeight: 600, textAlign: "center", maxWidth: 72, lineHeight: 1.3 }}>
|
||
{rideCounts[park.id]} {coastersOnly
|
||
? (rideCounts[park.id] === 1 ? "coaster" : "coasters")
|
||
: (rideCounts[park.id] === 1 ? "ride" : "rides")} operating
|
||
</div>
|
||
)}
|
||
</Link>
|
||
</td>
|
||
|
||
{weekDates.map((date, i) => (
|
||
<DayCell
|
||
key={date}
|
||
dayData={parkData[date]}
|
||
isWeekend={parsedDates[i].isWeekend}
|
||
tzAbbr={tzAbbr}
|
||
/>
|
||
))}
|
||
</tr>
|
||
);
|
||
}
|
||
|
||
export function WeekCalendar({ parks, weekDates, data, grouped, rideCounts, coastersOnly, openParkIds, closingParkIds, weatherDelayParkIds }: WeekCalendarProps) {
|
||
const today = getTodayLocal();
|
||
const parsedDates = weekDates.map(parseDate);
|
||
|
||
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", paddingRight: 16 }}>
|
||
<table style={{
|
||
borderCollapse: "collapse",
|
||
width: "100%",
|
||
minWidth: 700,
|
||
tableLayout: "fixed",
|
||
}}>
|
||
<colgroup>
|
||
<col style={{ width: 240 }} />
|
||
{weekDates.map((d) => (
|
||
<col key={d} style={{ width: 130 }} />
|
||
))}
|
||
</colgroup>
|
||
|
||
<thead>
|
||
{/* 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];
|
||
const showMonth = i === 0 || pd.month !== parsedDates[i - 1].month;
|
||
return (
|
||
<th key={date} style={{
|
||
...thDayStyle,
|
||
background: pd.isWeekend ? "var(--color-weekend-header)" : "var(--color-bg)",
|
||
paddingBottom: 0,
|
||
paddingTop: 8,
|
||
fontSize: "0.65rem",
|
||
color: "var(--color-text-muted)",
|
||
letterSpacing: "0.06em",
|
||
textTransform: "uppercase",
|
||
borderBottom: "none",
|
||
}}>
|
||
{showMonth ? `${MONTHS[pd.month]} ${new Date(date + "T00:00:00").getFullYear() !== firstYear ? new Date(date + "T00:00:00").getFullYear() : ""}` : ""}
|
||
</th>
|
||
);
|
||
})}
|
||
</tr>
|
||
)}
|
||
|
||
{/* Day header */}
|
||
<tr>
|
||
<th style={thParkStyle}>
|
||
<span style={{ color: "var(--color-text-muted)", fontSize: "0.65rem", letterSpacing: "0.08em", textTransform: "uppercase" }}>
|
||
Park
|
||
</span>
|
||
</th>
|
||
{weekDates.map((date, i) => {
|
||
const pd = parsedDates[i];
|
||
const isToday = date === today;
|
||
return (
|
||
<th key={date} style={{
|
||
...thDayStyle,
|
||
background: isToday
|
||
? "var(--color-today-bg)"
|
||
: pd.isWeekend
|
||
? "var(--color-weekend-header)"
|
||
: "var(--color-bg)",
|
||
borderBottom: isToday
|
||
? "2px solid var(--color-today-border)"
|
||
: `1px solid var(--color-border)`,
|
||
}}>
|
||
<div style={{
|
||
fontSize: "0.65rem",
|
||
textTransform: "uppercase",
|
||
letterSpacing: "0.06em",
|
||
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: "1rem",
|
||
fontWeight: isToday ? 700 : pd.isWeekend ? 600 : 400,
|
||
color: isToday ? "var(--color-today-text)" : pd.isWeekend ? "var(--color-text)" : "var(--color-text-muted)",
|
||
}}>
|
||
{pd.day}
|
||
</div>
|
||
</th>
|
||
);
|
||
})}
|
||
</tr>
|
||
</thead>
|
||
|
||
<tbody>
|
||
{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] ?? {}}
|
||
rideCounts={rideCounts}
|
||
coastersOnly={coastersOnly}
|
||
openParkIds={openParkIds}
|
||
closingParkIds={closingParkIds}
|
||
weatherDelayParkIds={weatherDelayParkIds}
|
||
/>
|
||
))}
|
||
</Fragment>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Shared styles ──────────────────────────────────────────────────────────
|
||
|
||
const thParkStyle: React.CSSProperties = {
|
||
position: "sticky",
|
||
left: 0,
|
||
zIndex: 10,
|
||
padding: "10px 14px",
|
||
textAlign: "left",
|
||
borderBottom: "1px solid var(--color-border)",
|
||
background: "var(--color-bg)",
|
||
verticalAlign: "bottom",
|
||
};
|
||
|
||
const thDayStyle: React.CSSProperties = {
|
||
padding: "10px 8px 8px",
|
||
textAlign: "center",
|
||
fontWeight: 400,
|
||
borderBottom: "1px solid var(--color-border)",
|
||
borderLeft: "1px solid var(--color-border)",
|
||
verticalAlign: "bottom",
|
||
};
|