All checks were successful
Build and Deploy / Build & Push (push) Successful in 4m22s
- 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>
199 lines
7.0 KiB
TypeScript
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>
|
|
);
|
|
}
|