All checks were successful
Build and Deploy / Build & Push (push) Successful in 4m22s
- isWithinOperatingWindow now accepts an IANA timezone and reads the current time in the park's local timezone via Intl.DateTimeFormat, fixing false positives when the server runs in UTC but parks store hours in local time (e.g. Pacific parks showing open at 6:50 AM EDT) - Remove the 1-hour pre-open buffer so parks are not marked open before their doors actually open; retain the 1-hour post-close grace period - Add getTimezoneAbbr() helper to derive the short tz label (EDT, PDT…) - All hours labels now display with the local timezone abbreviation (e.g. "10am – 6pm PDT") in WeekCalendar, ParkCard, and ParkMonthCalendar Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
397 lines
12 KiB
TypeScript
397 lines
12 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;
|
||
}
|
||
|
||
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: 56,
|
||
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: 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.78rem",
|
||
fontWeight: 600,
|
||
letterSpacing: "-0.01em",
|
||
whiteSpace: "nowrap",
|
||
}}>
|
||
{dayData.hoursLabel} {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: "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.78rem",
|
||
fontWeight: 600,
|
||
letterSpacing: "-0.01em",
|
||
whiteSpace: "nowrap",
|
||
}}>
|
||
{dayData.hoursLabel} {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)",
|
||
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,
|
||
rideCounts,
|
||
coastersOnly,
|
||
}: {
|
||
park: Park;
|
||
parkIdx: number;
|
||
weekDates: string[];
|
||
parsedDates: ReturnType<typeof parseDate>[];
|
||
parkData: Record<string, DayData>;
|
||
rideCounts?: Record<string, number>;
|
||
coastersOnly?: boolean;
|
||
}) {
|
||
const rowBg = parkIdx % 2 === 0 ? "var(--color-bg)" : "var(--color-surface)";
|
||
const tzAbbr = getTimezoneAbbr(park.timezone);
|
||
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)",
|
||
whiteSpace: "nowrap",
|
||
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>
|
||
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||
<span style={{ fontWeight: 500, fontSize: "0.85rem", lineHeight: 1.2, color: "var(--color-text)" }}>
|
||
{park.name}
|
||
</span>
|
||
{rideCounts?.[park.id] !== undefined && (
|
||
<span style={{
|
||
width: 7,
|
||
height: 7,
|
||
borderRadius: "50%",
|
||
background: "var(--color-open-text)",
|
||
flexShrink: 0,
|
||
boxShadow: "0 0 5px var(--color-open-text)",
|
||
}} />
|
||
)}
|
||
</div>
|
||
<div style={{ fontSize: "0.7rem", color: "var(--color-text-muted)", marginTop: 2 }}>
|
||
{park.location.city}, {park.location.state}
|
||
</div>
|
||
</div>
|
||
{rideCounts?.[park.id] !== undefined && (
|
||
<div style={{ fontSize: "0.72rem", color: "var(--color-open-hours)", fontWeight: 600, whiteSpace: "nowrap", flexShrink: 0 }}>
|
||
{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 }: 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" }}>
|
||
<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}
|
||
/>
|
||
))}
|
||
</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",
|
||
};
|