Files
SixFlagsSuperCalendar/components/WeekCalendar.tsx
josh 548c7ae09e feat: initial project scaffold with CI/CD and Docker deployment
Next.js 15 + Tailwind CSS v4 week calendar showing Six Flags park hours.
Scrapes the internal CloudFront API, stores results in SQLite.
Includes Dockerfile (Debian/Playwright-compatible), docker-compose, and
Gitea Actions pipeline that builds and pushes to the container registry.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 00:48:09 -04:00

262 lines
9.6 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 type { Park } from "@/lib/scrapers/types";
import type { DayData } from "@/lib/db";
interface WeekCalendarProps {
parks: Park[];
weekDates: string[]; // 7 dates, YYYY-MM-DD, SunSat
data: Record<string, Record<string, DayData>>; // parkId → date → DayData
}
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,
};
}
export function WeekCalendar({ parks, weekDates, data }: WeekCalendarProps) {
const today = new Date().toISOString().slice(0, 10);
const parsedDates = weekDates.map(parseDate);
// Detect month boundaries for column headers
const firstMonth = parsedDates[0].month;
const firstYear = new Date(weekDates[0] + "T00:00:00").getFullYear();
return (
<div style={{ overflowX: "auto", overflowY: "visible" }}>
<table style={{
borderCollapse: "collapse",
width: "100%",
minWidth: 700,
tableLayout: "fixed",
}}>
<colgroup>
{/* Park name column */}
<col style={{ width: 172 }} />
{weekDates.map((d) => (
<col key={d} style={{ width: 120 }} />
))}
</colgroup>
<thead>
{/* Month header — only show if we cross a month boundary */}
{parsedDates.some((d) => d.month !== firstMonth) && (
<tr>
<th style={thParkStyle} />
{weekDates.map((date, i) => {
const pd = parsedDates[i];
// Only render the month label on the first day of each new month (or the first column)
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.7rem",
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.7rem", 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.7rem",
textTransform: "uppercase",
letterSpacing: "0.06em",
color: isToday ? "var(--color-today-text)" : pd.isWeekend ? "var(--color-text)" : "var(--color-text-muted)",
marginBottom: 2,
}}>
{DOW[pd.dow]}
</div>
<div style={{
fontSize: "1.05rem",
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>
{parks.map((park, parkIdx) => {
const parkData = data[park.id] ?? {};
return (
<tr
key={park.id}
style={{
background: parkIdx % 2 === 0 ? "var(--color-bg)" : "var(--color-surface)",
}}
>
{/* Park name */}
<td style={{
...tdParkStyle,
background: parkIdx % 2 === 0 ? "var(--color-bg)" : "var(--color-surface)",
}}>
<div style={{ fontWeight: 500, color: "var(--color-text)", fontSize: "0.8rem", lineHeight: 1.2 }}>
{park.shortName}
</div>
<div style={{ fontSize: "0.65rem", color: "var(--color-text-muted)", marginTop: 2 }}>
{park.location.city}, {park.location.state}
</div>
</td>
{/* Day cells */}
{weekDates.map((date, i) => {
const pd = parsedDates[i];
const isToday = date === today;
const dayData = parkData[date];
if (!dayData) {
// No data scraped yet
return (
<td key={date} style={{
...tdBase,
background: isToday ? "var(--color-today-bg)" : pd.isWeekend ? "var(--color-weekend-header)" : "transparent",
borderLeft: isToday ? "1px solid var(--color-today-border)" : undefined,
borderRight: isToday ? "1px solid var(--color-today-border)" : undefined,
}}>
<span style={{ color: "var(--color-text-dim)", fontSize: "0.65rem" }}></span>
</td>
);
}
// Treat open-but-no-hours the same as closed (stale data or missing hours)
if (!dayData.isOpen || !dayData.hoursLabel) {
return (
<td key={date} style={{
...tdBase,
background: isToday ? "var(--color-today-bg)" : pd.isWeekend ? "var(--color-weekend-header)" : "transparent",
borderLeft: isToday ? "1px solid var(--color-today-border)" : undefined,
borderRight: isToday ? "1px solid var(--color-today-border)" : undefined,
}}>
<span style={{ color: "var(--color-text-dim)", fontSize: "0.65rem" }}>Closed</span>
</td>
);
}
// Open with confirmed hours
return (
<td key={date} style={{
...tdBase,
padding: 4,
borderLeft: isToday ? "1px solid var(--color-today-border)" : undefined,
borderRight: isToday ? "1px solid var(--color-today-border)" : undefined,
}}>
<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",
}}>
<span style={{
color: "var(--color-open-hours)",
fontSize: "0.72rem",
fontWeight: 500,
letterSpacing: "-0.01em",
whiteSpace: "nowrap",
}}>
{dayData.hoursLabel}
</span>
</div>
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</div>
);
}
// ── Shared styles ──────────────────────────────────────────────────────────
const thParkStyle: React.CSSProperties = {
position: "sticky",
left: 0,
zIndex: 10,
padding: "10px 12px",
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",
};
const tdParkStyle: React.CSSProperties = {
position: "sticky",
left: 0,
zIndex: 5,
padding: "10px 12px",
borderBottom: "1px solid var(--color-border)",
borderRight: "1px solid var(--color-border)",
whiteSpace: "nowrap",
verticalAlign: "middle",
};
const tdBase: React.CSSProperties = {
padding: 0,
textAlign: "center",
verticalAlign: "middle",
borderBottom: "1px solid var(--color-border)",
borderLeft: "1px solid var(--color-border)",
height: 52,
};