Files
SixFlagsSuperCalendar/components/WeekCalendar.tsx
Josh Wright fd99f6f390
All checks were successful
Build and Deploy / Build & Push (push) Successful in 58s
fix: allow ride count to wrap below park name on narrow columns
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 15:30:56 -04:00

428 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, SunSat
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",
flexWrap: "wrap",
padding: "10px 14px",
gap: 10,
}}>
<div style={{ minWidth: 0, flex: 1 }}>
<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.7rem", color: "var(--color-text-muted)", fontWeight: 500, textAlign: "right" }}>
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: "right" }}>
{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",
};