diff --git a/app/page.tsx b/app/page.tsx index f0fa4dc..149d6ac 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -72,7 +72,7 @@ export default async function HomePage({ searchParams }: PageProps) { 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); + return isWithinOperatingWindow(dayData.hoursLabel, p.timezone); }); const results = await Promise.all( openTodayParks.map(async (p) => { diff --git a/app/park/[id]/page.tsx b/app/park/[id]/page.tsx index 15345a8..7588f43 100644 --- a/app/park/[id]/page.tsx +++ b/app/park/[id]/page.tsx @@ -56,7 +56,7 @@ export default async function ParkPage({ params, searchParams }: PageProps) { // Determine if we're within the 1h-before-open to 1h-after-close window. const withinWindow = todayData?.hoursLabel - ? isWithinOperatingWindow(todayData.hoursLabel) + ? isWithinOperatingWindow(todayData.hoursLabel, park.timezone) : false; if (queueTimesId) { @@ -125,6 +125,7 @@ export default async function ParkPage({ params, searchParams }: PageProps) { month={month} monthData={monthData} today={today} + timezone={park.timezone} /> diff --git a/components/ParkCard.tsx b/components/ParkCard.tsx index b3e0fcf..eaa8f51 100644 --- a/components/ParkCard.tsx +++ b/components/ParkCard.tsx @@ -1,6 +1,7 @@ import Link from "next/link"; import type { Park } from "@/lib/scrapers/types"; import type { DayData } from "@/lib/db"; +import { getTimezoneAbbr } from "@/lib/env"; interface ParkCardProps { park: Park; @@ -15,6 +16,7 @@ const DOW = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", " export function ParkCard({ park, weekDates, parkData, today, openRideCount, coastersOnly }: ParkCardProps) { const openDays = weekDates.filter((d) => parkData[d]?.isOpen && parkData[d]?.hoursLabel); + const tzAbbr = getTimezoneAbbr(park.timezone); const isOpenToday = openDays.includes(today); return ( @@ -152,7 +154,7 @@ export function ParkCard({ park, weekDates, parkData, today, openRideCount, coas ? "var(--color-today-text)" : "var(--color-open-hours)", }}> - {dayData.hoursLabel} + {dayData.hoursLabel} {tzAbbr} diff --git a/components/ParkMonthCalendar.tsx b/components/ParkMonthCalendar.tsx index 2185f11..49164b9 100644 --- a/components/ParkMonthCalendar.tsx +++ b/components/ParkMonthCalendar.tsx @@ -1,5 +1,6 @@ import Link from "next/link"; import type { DayData } from "@/lib/db"; +import { getTimezoneAbbr } from "@/lib/env"; interface ParkMonthCalendarProps { parkId: string; @@ -7,6 +8,7 @@ interface ParkMonthCalendarProps { month: number; // 1-indexed monthData: Record; // 'YYYY-MM-DD' → DayData today: string; // YYYY-MM-DD + timezone: string; // IANA timezone, e.g. "America/New_York" } const DOW_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; @@ -29,7 +31,8 @@ function daysInMonth(year: number, month: number): number { return new Date(year, month, 0).getDate(); } -export function ParkMonthCalendar({ parkId, year, month, monthData, today }: ParkMonthCalendarProps) { +export function ParkMonthCalendar({ parkId, year, month, monthData, today, timezone }: ParkMonthCalendarProps) { + const tzAbbr = getTimezoneAbbr(timezone); const firstDow = new Date(year, month - 1, 1).getDay(); // 0=Sun const totalDays = daysInMonth(year, month); @@ -184,7 +187,7 @@ export function ParkMonthCalendar({ parkId, year, month, monthData, today }: Par Passholder
- {dayData.hoursLabel} + {dayData.hoursLabel} {tzAbbr}
) : isOpen ? ( @@ -195,7 +198,7 @@ export function ParkMonthCalendar({ parkId, year, month, monthData, today }: Par padding: "3px 6px", }}>
- {dayData.hoursLabel} + {dayData.hoursLabel} {tzAbbr}
) : ( diff --git a/components/WeekCalendar.tsx b/components/WeekCalendar.tsx index 678de2a..73ba085 100644 --- a/components/WeekCalendar.tsx +++ b/components/WeekCalendar.tsx @@ -3,7 +3,7 @@ import Link from "next/link"; import type { Park } from "@/lib/scrapers/types"; import type { DayData } from "@/lib/db"; import type { Region } from "@/lib/parks"; -import { getTodayLocal } from "@/lib/env"; +import { getTodayLocal, getTimezoneAbbr } from "@/lib/env"; interface WeekCalendarProps { parks: Park[]; @@ -33,9 +33,11 @@ function parseDate(iso: string) { function DayCell({ dayData, isWeekend, + tzAbbr, }: { dayData: DayData | undefined; isWeekend: boolean; + tzAbbr: string; }) { const base: React.CSSProperties = { padding: 0, @@ -98,7 +100,7 @@ function DayCell({ letterSpacing: "-0.01em", whiteSpace: "nowrap", }}> - {dayData.hoursLabel} + {dayData.hoursLabel} {tzAbbr} @@ -126,7 +128,7 @@ function DayCell({ letterSpacing: "-0.01em", whiteSpace: "nowrap", }}> - {dayData.hoursLabel} + {dayData.hoursLabel} {tzAbbr} @@ -177,6 +179,7 @@ function ParkRow({ coastersOnly?: boolean; }) { const rowBg = parkIdx % 2 === 0 ? "var(--color-bg)" : "var(--color-surface)"; + const tzAbbr = getTimezoneAbbr(park.timezone); return ( ))} diff --git a/lib/env.ts b/lib/env.ts index 186c060..7c32382 100644 --- a/lib/env.ts +++ b/lib/env.ts @@ -35,11 +35,26 @@ export function getTodayLocal(): string { } /** - * Returns true when the current local time is within 1 hour before open - * or 1 hour after close, based on a hoursLabel like "10am – 6pm". - * Falls back to true when the label can't be parsed. + * Returns the short timezone abbreviation for a given IANA timezone, + * e.g. "America/Los_Angeles" → "PDT" or "PST". */ -export function isWithinOperatingWindow(hoursLabel: string): boolean { +export function getTimezoneAbbr(timezone: string): string { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone: timezone, + timeZoneName: "short", + }).formatToParts(new Date()); + return parts.find((p) => p.type === "timeZoneName")?.value ?? ""; +} + +/** + * Returns true when the current time in the park's timezone is within + * the operating window (open time through 1 hour after close), based on + * a hoursLabel like "10am – 6pm". Falls back to true when unparseable. + * + * Uses the park's IANA timezone so a Pacific park's "10am" is correctly + * compared to Pacific time regardless of where the server is running. + */ +export function isWithinOperatingWindow(hoursLabel: string, timezone: string): boolean { const m = hoursLabel.match( /^(\d+)(?::(\d+))?(am|pm)\s*[–-]\s*(\d+)(?::(\d+))?(am|pm)$/i ); @@ -53,7 +68,17 @@ export function isWithinOperatingWindow(hoursLabel: string): boolean { }; const openMin = toMinutes(m[1], m[2], m[3]); const closeMin = toMinutes(m[4], m[5], m[6]); - const now = new Date(); - const nowMin = now.getHours() * 60 + now.getMinutes(); - return nowMin >= openMin - 60 && nowMin <= closeMin + 60; + + // Get the current time in the park's local timezone. + const parts = new Intl.DateTimeFormat("en-US", { + timeZone: timezone, + hour: "numeric", + minute: "2-digit", + hour12: false, + }).formatToParts(new Date()); + const h = parseInt(parts.find((p) => p.type === "hour")?.value ?? "0", 10); + const min = parseInt(parts.find((p) => p.type === "minute")?.value ?? "0", 10); + const nowMin = (h % 24) * 60 + min; + + return nowMin >= openMin && nowMin <= closeMin + 60; }