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>
230 lines
7.8 KiB
TypeScript
230 lines
7.8 KiB
TypeScript
import Link from "next/link";
|
|
import type { DayData } from "@/lib/db";
|
|
import { getTimezoneAbbr } from "@/lib/env";
|
|
|
|
interface ParkMonthCalendarProps {
|
|
parkId: string;
|
|
year: number;
|
|
month: number; // 1-indexed
|
|
monthData: Record<string, DayData>; // 'YYYY-MM-DD' → DayData
|
|
today: string; // YYYY-MM-DD
|
|
timezone: string; // IANA timezone, e.g. "America/New_York"
|
|
}
|
|
|
|
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, timezone }: ParkMonthCalendarProps) {
|
|
const tzAbbr = getTimezoneAbbr(timezone);
|
|
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: 96,
|
|
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";
|
|
|
|
const bg = isToday
|
|
? "var(--color-today-bg)"
|
|
: isWeekend
|
|
? "var(--color-weekend-header)"
|
|
: "transparent";
|
|
|
|
return (
|
|
<div key={ci} style={{
|
|
minHeight: 96,
|
|
padding: "10px 12px",
|
|
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: 5,
|
|
}}>
|
|
{/* Date number */}
|
|
<span style={{
|
|
fontSize: "0.95rem",
|
|
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.75rem", color: "var(--color-text-dim)" }}>—</span>
|
|
) : isPH && isOpen ? (
|
|
<div style={{
|
|
background: "var(--color-ph-bg)",
|
|
border: "1px solid var(--color-ph-border)",
|
|
borderRadius: 5,
|
|
padding: "3px 6px",
|
|
}}>
|
|
<div style={{ fontSize: "0.6rem", fontWeight: 700, color: "var(--color-ph-label)", textTransform: "uppercase", letterSpacing: "0.05em" }}>
|
|
Passholder
|
|
</div>
|
|
<div style={{ fontSize: "0.65rem", color: "var(--color-ph-hours)", marginTop: 2 }}>
|
|
{dayData.hoursLabel} {tzAbbr}
|
|
</div>
|
|
</div>
|
|
) : isOpen ? (
|
|
<div style={{
|
|
background: "var(--color-open-bg)",
|
|
border: "1px solid var(--color-open-border)",
|
|
borderRadius: 5,
|
|
padding: "3px 6px",
|
|
}}>
|
|
<div style={{ fontSize: "0.65rem", color: "var(--color-open-hours)" }}>
|
|
{dayData.hoursLabel} {tzAbbr}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<span style={{ fontSize: "1rem", 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",
|
|
};
|