From 7c00ae50001119814f261dd51c969dbed038b214 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Sun, 5 Apr 2026 11:06:03 -0400 Subject: [PATCH] feat: schedule targeted refresh at each park's exact opening time In addition to the 2-minute polling interval, compute milliseconds until each park's opening time (from hoursLabel + park timezone) and schedule a setTimeout to fire 30s after opening. This ensures the open indicator and ride counts appear immediately rather than waiting up to 2 minutes. Co-Authored-By: Claude Sonnet 4.6 --- components/HomePageClient.tsx | 50 +++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/components/HomePageClient.tsx b/components/HomePageClient.tsx index 5eb88ae..3f40343 100644 --- a/components/HomePageClient.tsx +++ b/components/HomePageClient.tsx @@ -11,6 +11,34 @@ 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"; @@ -54,6 +82,28 @@ export function HomePageClient({ 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[] = []; + + 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 + OPEN_REFRESH_BUFFER_MS)); + } + } + + 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);