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>
This commit is contained in:
2026-04-04 00:48:09 -04:00
parent af6aa29474
commit 548c7ae09e
26 changed files with 9602 additions and 0 deletions

261
components/WeekCalendar.tsx Normal file
View File

@@ -0,0 +1,261 @@
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,
};