All checks were successful
Build and Deploy / Build & Push (push) Successful in 50s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
260 lines
9.3 KiB
TypeScript
260 lines
9.3 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>
|
|
|
|
{/*
|
|
All day cells in ONE flat grid — eliminates per-week wrapper
|
|
divs that caused independent row heights and the slant effect.
|
|
Row height is controlled responsively via .park-calendar-grid CSS:
|
|
mobile = 72px fixed, sm+ = minmax(96px, auto).
|
|
*/}
|
|
<div
|
|
className="park-calendar-grid"
|
|
style={{ display: "grid", gridTemplateColumns: "repeat(7, 1fr)" }}
|
|
>
|
|
{cells.map((cell, idx) => {
|
|
const ci = idx % 7;
|
|
const isLastRow = idx >= cells.length - 7;
|
|
const borderBottom = !isLastRow ? "1px solid var(--color-border-subtle)" : "none";
|
|
const borderRight = ci < 6 ? "1px solid var(--color-border-subtle)" : "none";
|
|
|
|
if (!cell.day || !cell.iso) {
|
|
return (
|
|
<div key={idx} style={{
|
|
overflow: "hidden",
|
|
background: ci === 0 || ci === 6 ? "var(--color-weekend-header)" : "transparent",
|
|
borderRight,
|
|
borderBottom,
|
|
}} />
|
|
);
|
|
}
|
|
|
|
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={idx} style={{
|
|
padding: "8px 8px",
|
|
overflow: "hidden",
|
|
background: bg,
|
|
borderRight,
|
|
borderBottom,
|
|
borderLeft: isToday ? "2px solid var(--color-today-border)" : "none",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: 8,
|
|
}}>
|
|
{/* Date number */}
|
|
<span style={{
|
|
fontSize: "0.88rem",
|
|
fontWeight: isToday ? 700 : isWeekend ? 600 : 400,
|
|
color: isToday
|
|
? "var(--color-today-text)"
|
|
: isWeekend
|
|
? "var(--color-text)"
|
|
: "var(--color-text-muted)",
|
|
lineHeight: 1,
|
|
flexShrink: 0,
|
|
}}>
|
|
{cell.day}
|
|
</span>
|
|
|
|
{/* ── Mobile status: colored dot only (sm:hidden) ──────── */}
|
|
{/* Cells are ~55px wide on mobile — no room for hours text */}
|
|
{!dayData ? (
|
|
<span className="sm:hidden" style={{ fontSize: "0.7rem", color: "var(--color-text-dim)" }}>—</span>
|
|
) : isOpen ? (
|
|
<div className="sm:hidden" style={{
|
|
width: 7, height: 7, borderRadius: "50%", flexShrink: 0,
|
|
background: isPH ? "var(--color-ph-border)" : "var(--color-open-border)",
|
|
}} />
|
|
) : null}
|
|
|
|
{/* ── Desktop status: full pill with hours (hidden sm:block) */}
|
|
{!dayData ? (
|
|
<span className="hidden sm:inline" style={{ fontSize: "0.75rem", color: "var(--color-text-dim)" }}>—</span>
|
|
) : isPH && isOpen ? (
|
|
<div className="hidden sm:block" style={{
|
|
background: "var(--color-ph-bg)",
|
|
border: "1px solid var(--color-ph-border)",
|
|
borderRadius: 5,
|
|
padding: "3px 6px",
|
|
textAlign: "center",
|
|
}}>
|
|
<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}
|
|
</div>
|
|
<div style={{ fontSize: "0.58rem", color: "var(--color-ph-label)", opacity: 0.75, marginTop: 1, letterSpacing: "0.04em" }}>
|
|
{tzAbbr}
|
|
</div>
|
|
</div>
|
|
) : isOpen ? (
|
|
<div className="hidden sm:block" style={{
|
|
background: "var(--color-open-bg)",
|
|
border: "1px solid var(--color-open-border)",
|
|
borderRadius: 5,
|
|
padding: "3px 6px",
|
|
textAlign: "center",
|
|
}}>
|
|
<div style={{ fontSize: "0.65rem", color: "var(--color-open-hours)" }}>
|
|
{dayData.hoursLabel}
|
|
</div>
|
|
<div style={{ fontSize: "0.58rem", color: "var(--color-open-hours)", opacity: 0.6, marginTop: 1, letterSpacing: "0.04em" }}>
|
|
{tzAbbr}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<span className="hidden sm:inline" 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: "10px 16px",
|
|
borderRadius: 8,
|
|
border: "1px solid var(--color-border)",
|
|
background: "var(--color-surface)",
|
|
color: "var(--color-text-muted)",
|
|
fontSize: "1rem",
|
|
lineHeight: 1,
|
|
textDecoration: "none",
|
|
minWidth: 44,
|
|
};
|