From c4c86a37962f11cdb65074e669f6f87ca8dd8ae6 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Sun, 5 Apr 2026 08:12:19 -0400 Subject: [PATCH] fix: use park timezone for operating window check; show tz in hours MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/page.tsx | 2 +- app/park/[id]/page.tsx | 3 ++- components/ParkCard.tsx | 4 +++- components/ParkMonthCalendar.tsx | 9 +++++--- components/WeekCalendar.tsx | 10 +++++--- lib/env.ts | 39 ++++++++++++++++++++++++++------ 6 files changed, 51 insertions(+), 16 deletions(-) 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; }