fix: use park timezone for operating window check; show tz in hours
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>
This commit is contained in:
Josh Wright
2026-04-05 08:12:19 -04:00
parent 7456ead430
commit c4c86a3796
6 changed files with 51 additions and 16 deletions

View File

@@ -72,7 +72,7 @@ export default async function HomePage({ searchParams }: PageProps) {
const openTodayParks = PARKS.filter((p) => { const openTodayParks = PARKS.filter((p) => {
const dayData = data[p.id]?.[today]; const dayData = data[p.id]?.[today];
if (!dayData?.isOpen || !QUEUE_TIMES_IDS[p.id] || !dayData.hoursLabel) return false; 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( const results = await Promise.all(
openTodayParks.map(async (p) => { openTodayParks.map(async (p) => {

View File

@@ -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. // Determine if we're within the 1h-before-open to 1h-after-close window.
const withinWindow = todayData?.hoursLabel const withinWindow = todayData?.hoursLabel
? isWithinOperatingWindow(todayData.hoursLabel) ? isWithinOperatingWindow(todayData.hoursLabel, park.timezone)
: false; : false;
if (queueTimesId) { if (queueTimesId) {
@@ -125,6 +125,7 @@ export default async function ParkPage({ params, searchParams }: PageProps) {
month={month} month={month}
monthData={monthData} monthData={monthData}
today={today} today={today}
timezone={park.timezone}
/> />
</section> </section>

View File

@@ -1,6 +1,7 @@
import Link from "next/link"; import Link from "next/link";
import type { Park } from "@/lib/scrapers/types"; import type { Park } from "@/lib/scrapers/types";
import type { DayData } from "@/lib/db"; import type { DayData } from "@/lib/db";
import { getTimezoneAbbr } from "@/lib/env";
interface ParkCardProps { interface ParkCardProps {
park: Park; park: Park;
@@ -15,6 +16,7 @@ const DOW = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "
export function ParkCard({ park, weekDates, parkData, today, openRideCount, coastersOnly }: ParkCardProps) { export function ParkCard({ park, weekDates, parkData, today, openRideCount, coastersOnly }: ParkCardProps) {
const openDays = weekDates.filter((d) => parkData[d]?.isOpen && parkData[d]?.hoursLabel); const openDays = weekDates.filter((d) => parkData[d]?.isOpen && parkData[d]?.hoursLabel);
const tzAbbr = getTimezoneAbbr(park.timezone);
const isOpenToday = openDays.includes(today); const isOpenToday = openDays.includes(today);
return ( return (
@@ -152,7 +154,7 @@ export function ParkCard({ park, weekDates, parkData, today, openRideCount, coas
? "var(--color-today-text)" ? "var(--color-today-text)"
: "var(--color-open-hours)", : "var(--color-open-hours)",
}}> }}>
{dayData.hoursLabel} {dayData.hoursLabel} {tzAbbr}
</span> </span>
</div> </div>
</div> </div>

View File

@@ -1,5 +1,6 @@
import Link from "next/link"; import Link from "next/link";
import type { DayData } from "@/lib/db"; import type { DayData } from "@/lib/db";
import { getTimezoneAbbr } from "@/lib/env";
interface ParkMonthCalendarProps { interface ParkMonthCalendarProps {
parkId: string; parkId: string;
@@ -7,6 +8,7 @@ interface ParkMonthCalendarProps {
month: number; // 1-indexed month: number; // 1-indexed
monthData: Record<string, DayData>; // 'YYYY-MM-DD' → DayData monthData: Record<string, DayData>; // 'YYYY-MM-DD' → DayData
today: string; // YYYY-MM-DD today: string; // YYYY-MM-DD
timezone: string; // IANA timezone, e.g. "America/New_York"
} }
const DOW_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; 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(); 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 firstDow = new Date(year, month - 1, 1).getDay(); // 0=Sun
const totalDays = daysInMonth(year, month); const totalDays = daysInMonth(year, month);
@@ -184,7 +187,7 @@ export function ParkMonthCalendar({ parkId, year, month, monthData, today }: Par
Passholder Passholder
</div> </div>
<div style={{ fontSize: "0.65rem", color: "var(--color-ph-hours)", marginTop: 2 }}> <div style={{ fontSize: "0.65rem", color: "var(--color-ph-hours)", marginTop: 2 }}>
{dayData.hoursLabel} {dayData.hoursLabel} {tzAbbr}
</div> </div>
</div> </div>
) : isOpen ? ( ) : isOpen ? (
@@ -195,7 +198,7 @@ export function ParkMonthCalendar({ parkId, year, month, monthData, today }: Par
padding: "3px 6px", padding: "3px 6px",
}}> }}>
<div style={{ fontSize: "0.65rem", color: "var(--color-open-hours)" }}> <div style={{ fontSize: "0.65rem", color: "var(--color-open-hours)" }}>
{dayData.hoursLabel} {dayData.hoursLabel} {tzAbbr}
</div> </div>
</div> </div>
) : ( ) : (

View File

@@ -3,7 +3,7 @@ import Link from "next/link";
import type { Park } from "@/lib/scrapers/types"; import type { Park } from "@/lib/scrapers/types";
import type { DayData } from "@/lib/db"; import type { DayData } from "@/lib/db";
import type { Region } from "@/lib/parks"; import type { Region } from "@/lib/parks";
import { getTodayLocal } from "@/lib/env"; import { getTodayLocal, getTimezoneAbbr } from "@/lib/env";
interface WeekCalendarProps { interface WeekCalendarProps {
parks: Park[]; parks: Park[];
@@ -33,9 +33,11 @@ function parseDate(iso: string) {
function DayCell({ function DayCell({
dayData, dayData,
isWeekend, isWeekend,
tzAbbr,
}: { }: {
dayData: DayData | undefined; dayData: DayData | undefined;
isWeekend: boolean; isWeekend: boolean;
tzAbbr: string;
}) { }) {
const base: React.CSSProperties = { const base: React.CSSProperties = {
padding: 0, padding: 0,
@@ -98,7 +100,7 @@ function DayCell({
letterSpacing: "-0.01em", letterSpacing: "-0.01em",
whiteSpace: "nowrap", whiteSpace: "nowrap",
}}> }}>
{dayData.hoursLabel} {dayData.hoursLabel} {tzAbbr}
</span> </span>
</div> </div>
</td> </td>
@@ -126,7 +128,7 @@ function DayCell({
letterSpacing: "-0.01em", letterSpacing: "-0.01em",
whiteSpace: "nowrap", whiteSpace: "nowrap",
}}> }}>
{dayData.hoursLabel} {dayData.hoursLabel} {tzAbbr}
</span> </span>
</div> </div>
</td> </td>
@@ -177,6 +179,7 @@ function ParkRow({
coastersOnly?: boolean; coastersOnly?: boolean;
}) { }) {
const rowBg = parkIdx % 2 === 0 ? "var(--color-bg)" : "var(--color-surface)"; const rowBg = parkIdx % 2 === 0 ? "var(--color-bg)" : "var(--color-surface)";
const tzAbbr = getTimezoneAbbr(park.timezone);
return ( return (
<tr <tr
className="park-row" className="park-row"
@@ -237,6 +240,7 @@ function ParkRow({
key={date} key={date}
dayData={parkData[date]} dayData={parkData[date]}
isWeekend={parsedDates[i].isWeekend} isWeekend={parsedDates[i].isWeekend}
tzAbbr={tzAbbr}
/> />
))} ))}
</tr> </tr>

View File

@@ -35,11 +35,26 @@ export function getTodayLocal(): string {
} }
/** /**
* Returns true when the current local time is within 1 hour before open * Returns the short timezone abbreviation for a given IANA timezone,
* or 1 hour after close, based on a hoursLabel like "10am 6pm". * e.g. "America/Los_Angeles" → "PDT" or "PST".
* Falls back to true when the label can't be parsed.
*/ */
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( const m = hoursLabel.match(
/^(\d+)(?::(\d+))?(am|pm)\s*[-]\s*(\d+)(?::(\d+))?(am|pm)$/i /^(\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 openMin = toMinutes(m[1], m[2], m[3]);
const closeMin = toMinutes(m[4], m[5], m[6]); const closeMin = toMinutes(m[4], m[5], m[6]);
const now = new Date();
const nowMin = now.getHours() * 60 + now.getMinutes(); // Get the current time in the park's local timezone.
return nowMin >= openMin - 60 && nowMin <= closeMin + 60; 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;
} }