Files
SixFlagsSuperCalendar/app/page.tsx
Josh Wright c4c86a3796
All checks were successful
Build and Deploy / Build & Push (push) Successful in 4m22s
fix: use park timezone for operating window check; show tz in hours
- 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>
2026-04-05 08:12:19 -04:00

199 lines
7.0 KiB
TypeScript

import { WeekCalendar } from "@/components/WeekCalendar";
import { MobileCardList } from "@/components/MobileCardList";
import { WeekNav } from "@/components/WeekNav";
import { Legend } from "@/components/Legend";
import { EmptyState } from "@/components/EmptyState";
import { PARKS, groupByRegion } from "@/lib/parks";
import { openDb, getDateRange } from "@/lib/db";
import { getTodayLocal, isWithinOperatingWindow } from "@/lib/env";
import { fetchLiveRides } from "@/lib/scrapers/queuetimes";
import { QUEUE_TIMES_IDS } from "@/lib/queue-times-map";
import { readParkMeta, getCoasterSet } from "@/lib/park-meta";
interface PageProps {
searchParams: Promise<{ week?: string; coasters?: string }>;
}
function getWeekStart(param: string | undefined): string {
if (param && /^\d{4}-\d{2}-\d{2}$/.test(param)) {
const d = new Date(param + "T00:00:00");
if (!isNaN(d.getTime())) {
d.setDate(d.getDate() - d.getDay());
return d.toISOString().slice(0, 10);
}
}
const todayIso = getTodayLocal();
const d = new Date(todayIso + "T00:00:00");
d.setDate(d.getDate() - d.getDay());
return d.toISOString().slice(0, 10);
}
function getWeekDates(sundayIso: string): string[] {
return Array.from({ length: 7 }, (_, i) => {
const d = new Date(sundayIso + "T00:00:00");
d.setDate(d.getDate() + i);
return d.toISOString().slice(0, 10);
});
}
function getCurrentWeekStart(): string {
const todayIso = getTodayLocal();
const d = new Date(todayIso + "T00:00:00");
d.setDate(d.getDate() - d.getDay());
return d.toISOString().slice(0, 10);
}
export default async function HomePage({ searchParams }: PageProps) {
const params = await searchParams;
const weekStart = getWeekStart(params.week);
const coastersOnly = params.coasters === "1";
const weekDates = getWeekDates(weekStart);
const endDate = weekDates[6];
const today = getTodayLocal();
const isCurrentWeek = weekStart === getCurrentWeekStart();
const db = openDb();
const data = getDateRange(db, weekStart, endDate);
db.close();
const scrapedCount = Object.values(data).reduce(
(sum, parkData) => sum + Object.keys(parkData).length,
0
);
// Fetch live ride counts for parks open today (cached 5 min via Queue-Times).
// Only shown when the current time is within 1h before open to 1h after close.
const parkMeta = readParkMeta();
const hasCoasterData = PARKS.some((p) => (parkMeta[p.id]?.coasters.length ?? 0) > 0);
let rideCounts: Record<string, number> = {};
let coasterCounts: Record<string, number> = {};
if (weekDates.includes(today)) {
const openTodayParks = PARKS.filter((p) => {
const dayData = data[p.id]?.[today];
if (!dayData?.isOpen || !QUEUE_TIMES_IDS[p.id] || !dayData.hoursLabel) return false;
return isWithinOperatingWindow(dayData.hoursLabel, p.timezone);
});
const results = await Promise.all(
openTodayParks.map(async (p) => {
const coasterSet = getCoasterSet(p.id, parkMeta);
const result = await fetchLiveRides(QUEUE_TIMES_IDS[p.id], coasterSet, 300);
const rideCount = result ? result.rides.filter((r) => r.isOpen).length : 0;
const coasterCount = result ? result.rides.filter((r) => r.isOpen && r.isCoaster).length : 0;
return { id: p.id, rideCount, coasterCount };
})
);
rideCounts = Object.fromEntries(
results.filter(({ rideCount }) => rideCount > 0).map(({ id, rideCount }) => [id, rideCount])
);
coasterCounts = Object.fromEntries(
results.filter(({ coasterCount }) => coasterCount > 0).map(({ id, coasterCount }) => [id, coasterCount])
);
}
const activeCounts = coastersOnly ? coasterCounts : rideCounts;
const visibleParks = PARKS.filter((park) =>
weekDates.some((date) => data[park.id]?.[date]?.isOpen)
);
const grouped = groupByRegion(visibleParks);
return (
<div style={{ minHeight: "100vh", background: "var(--color-bg)" }}>
{/* ── Header ─────────────────────────────────────────────────────────── */}
<header style={{
position: "sticky",
top: 0,
zIndex: 20,
background: "var(--color-bg)",
borderBottom: "1px solid var(--color-border)",
}}>
{/* Row 1: Title + park count */}
<div style={{
padding: "12px 16px 10px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
}}>
<span style={{
fontSize: "1.1rem",
fontWeight: 700,
color: "var(--color-text)",
letterSpacing: "-0.02em",
}}>
Thoosie Calendar
</span>
<span style={{
background: visibleParks.length > 0 ? "var(--color-open-bg)" : "var(--color-surface)",
border: `1px solid ${visibleParks.length > 0 ? "var(--color-open-border)" : "var(--color-border)"}`,
borderRadius: 20,
padding: "4px 14px",
fontSize: "0.78rem",
color: visibleParks.length > 0 ? "var(--color-open-hours)" : "var(--color-text-muted)",
fontWeight: 600,
}}>
{visibleParks.length} of {PARKS.length} parks open
</span>
</div>
{/* Row 2: Week nav + legend (legend hidden on mobile) */}
<div style={{
padding: "8px 16px 10px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 16,
borderTop: "1px solid var(--color-border-subtle)",
}}>
<WeekNav
weekStart={weekStart}
weekDates={weekDates}
isCurrentWeek={isCurrentWeek}
coastersOnly={coastersOnly}
hasCoasterData={hasCoasterData}
/>
<div className="hidden sm:flex">
<Legend />
</div>
</div>
</header>
{/* ── Main content ───────────────────────────────────────────────────── */}
<main className="px-4 sm:px-6 pb-12">
{scrapedCount === 0 ? (
<EmptyState />
) : (
<>
{/* Mobile: card list (hidden on lg+) */}
<div className="lg:hidden">
<MobileCardList
grouped={grouped}
weekDates={weekDates}
data={data}
today={today}
rideCounts={activeCounts}
coastersOnly={coastersOnly}
/>
</div>
{/* Desktop: week table (hidden below lg) */}
<div className="hidden lg:block">
<WeekCalendar
parks={visibleParks}
weekDates={weekDates}
data={data}
grouped={grouped}
rideCounts={activeCounts}
coastersOnly={coastersOnly}
/>
</div>
</>
)}
</main>
</div>
);
}