All checks were successful
Build and Deploy / Build & Push (push) Successful in 49s
Park open indicator now derives from scheduled hours, not ride counts. Parks with queue-times coverage but 0 open rides (e.g. storm) show a "⛈ Weather Delay" notice instead of a ride count on both desktop and mobile. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
264 lines
9.2 KiB
TypeScript
264 lines
9.2 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { WeekCalendar } from "./WeekCalendar";
|
|
import { MobileCardList } from "./MobileCardList";
|
|
import { WeekNav } from "./WeekNav";
|
|
import { Legend } from "./Legend";
|
|
import { EmptyState } from "./EmptyState";
|
|
import { PARKS, groupByRegion } from "@/lib/parks";
|
|
import type { DayData } from "@/lib/db";
|
|
|
|
const REFRESH_INTERVAL_MS = 2 * 60 * 1000; // 2 minutes
|
|
const OPEN_REFRESH_BUFFER_MS = 30_000; // 30s after opening time before hitting the API
|
|
|
|
/** Parse the opening hour/minute from a hoursLabel like "10am", "10:30am", "11am". */
|
|
function parseOpenTime(hoursLabel: string): { hour: number; minute: number } | null {
|
|
const openPart = hoursLabel.split(" - ")[0].trim();
|
|
const match = openPart.match(/^(\d+)(?::(\d+))?(am|pm)$/i);
|
|
if (!match) return null;
|
|
let hour = parseInt(match[1], 10);
|
|
const minute = match[2] ? parseInt(match[2], 10) : 0;
|
|
const period = match[3].toLowerCase();
|
|
if (period === "pm" && hour !== 12) hour += 12;
|
|
if (period === "am" && hour === 12) hour = 0;
|
|
return { hour, minute };
|
|
}
|
|
|
|
/** Milliseconds from now until a given local clock time in a timezone. Negative if already past. */
|
|
function msUntilLocalTime(hour: number, minute: number, timezone: string): number {
|
|
const now = new Date();
|
|
const parts = new Intl.DateTimeFormat("en-US", {
|
|
timeZone: timezone,
|
|
hour: "numeric",
|
|
minute: "2-digit",
|
|
hour12: false,
|
|
}).formatToParts(now);
|
|
const localHour = parseInt(parts.find(p => p.type === "hour")!.value, 10) % 24;
|
|
const localMinute = parseInt(parts.find(p => p.type === "minute")!.value, 10);
|
|
return ((hour * 60 + minute) - (localHour * 60 + localMinute)) * 60_000;
|
|
}
|
|
|
|
const COASTER_MODE_KEY = "coasterMode";
|
|
|
|
interface HomePageClientProps {
|
|
weekStart: string;
|
|
weekDates: string[];
|
|
today: string;
|
|
isCurrentWeek: boolean;
|
|
data: Record<string, Record<string, DayData>>;
|
|
rideCounts: Record<string, number>;
|
|
coasterCounts: Record<string, number>;
|
|
openParkIds: string[];
|
|
closingParkIds: string[];
|
|
weatherDelayParkIds: string[];
|
|
hasCoasterData: boolean;
|
|
scrapedCount: number;
|
|
}
|
|
|
|
export function HomePageClient({
|
|
weekStart,
|
|
weekDates,
|
|
today,
|
|
isCurrentWeek,
|
|
data,
|
|
rideCounts,
|
|
coasterCounts,
|
|
openParkIds,
|
|
closingParkIds,
|
|
weatherDelayParkIds,
|
|
hasCoasterData,
|
|
scrapedCount,
|
|
}: HomePageClientProps) {
|
|
const router = useRouter();
|
|
const [coastersOnly, setCoastersOnly] = useState(false);
|
|
|
|
// Hydrate from localStorage after mount to avoid SSR mismatch.
|
|
useEffect(() => {
|
|
setCoastersOnly(localStorage.getItem(COASTER_MODE_KEY) === "true");
|
|
}, []);
|
|
|
|
// Periodically re-fetch server data (ride counts, open status) without a full page reload.
|
|
useEffect(() => {
|
|
if (!isCurrentWeek) return;
|
|
const id = setInterval(() => router.refresh(), REFRESH_INTERVAL_MS);
|
|
return () => clearInterval(id);
|
|
}, [isCurrentWeek, router]);
|
|
|
|
// Schedule a targeted refresh at each park's exact opening time so the
|
|
// open indicator and ride counts appear immediately rather than waiting
|
|
// up to 2 minutes for the next polling cycle.
|
|
useEffect(() => {
|
|
if (!isCurrentWeek) return;
|
|
const timeouts: ReturnType<typeof setTimeout>[] = [];
|
|
|
|
for (const park of PARKS) {
|
|
const dayData = data[park.id]?.[today];
|
|
if (!dayData?.isOpen || !dayData.hoursLabel) continue;
|
|
const openTime = parseOpenTime(dayData.hoursLabel);
|
|
if (!openTime) continue;
|
|
const ms = msUntilLocalTime(openTime.hour, openTime.minute, park.timezone);
|
|
// Only schedule if opening is still in the future (within the next 24h)
|
|
if (ms > 0 && ms < 24 * 60 * 60 * 1000) {
|
|
timeouts.push(setTimeout(() => router.refresh(), ms)); // mark as open
|
|
timeouts.push(setTimeout(() => router.refresh(), ms + OPEN_REFRESH_BUFFER_MS)); // pick up ride counts
|
|
}
|
|
}
|
|
|
|
return () => timeouts.forEach(clearTimeout);
|
|
}, [isCurrentWeek, today, data, router]);
|
|
|
|
// Remember the current week so the park page back button returns here.
|
|
useEffect(() => {
|
|
localStorage.setItem("lastWeek", weekStart);
|
|
}, [weekStart]);
|
|
|
|
const toggle = () => {
|
|
const next = !coastersOnly;
|
|
setCoastersOnly(next);
|
|
localStorage.setItem(COASTER_MODE_KEY, String(next));
|
|
};
|
|
|
|
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 + controls */}
|
|
<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>
|
|
|
|
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
|
{hasCoasterData && (
|
|
<button
|
|
onClick={toggle}
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 5,
|
|
padding: "4px 12px",
|
|
borderRadius: 20,
|
|
border: coastersOnly
|
|
? "1px solid var(--color-accent)"
|
|
: "1px solid var(--color-border)",
|
|
background: coastersOnly
|
|
? "var(--color-accent-muted)"
|
|
: "var(--color-surface)",
|
|
color: coastersOnly
|
|
? "var(--color-accent)"
|
|
: "var(--color-text-muted)",
|
|
fontSize: "0.72rem",
|
|
fontWeight: 600,
|
|
cursor: "pointer",
|
|
transition: "background 150ms ease, border-color 150ms ease, color 150ms ease",
|
|
whiteSpace: "nowrap",
|
|
}}
|
|
>
|
|
🎢 Coaster Mode
|
|
</button>
|
|
)}
|
|
|
|
<span className="hidden sm:inline-flex" 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,
|
|
alignItems: "center",
|
|
whiteSpace: "nowrap",
|
|
}}>
|
|
{visibleParks.length} of {PARKS.length} parks open this week
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Row 2: Week nav + legend */}
|
|
<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}
|
|
/>
|
|
<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}
|
|
openParkIds={openParkIds}
|
|
closingParkIds={closingParkIds}
|
|
weatherDelayParkIds={weatherDelayParkIds}
|
|
/>
|
|
</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}
|
|
openParkIds={openParkIds}
|
|
closingParkIds={closingParkIds}
|
|
weatherDelayParkIds={weatherDelayParkIds}
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|