/** * Environment variable helpers. */ /** * Parse a staleness window from an env var string (interpreted as hours). * Falls back to `defaultHours` when the value is missing, non-numeric, * non-finite, or <= 0 — preventing NaN from silently breaking staleness checks. */ export function parseStalenessHours(envVar: string | undefined, defaultHours: number): number { const parsed = parseInt(envVar ?? "", 10); return Number.isFinite(parsed) && parsed > 0 ? parsed : defaultHours; } const APP_TIMEZONE = "America/New_York"; /** * Returns today's date as YYYY-MM-DD in Eastern time with a 3 AM switchover. * Uses Intl.DateTimeFormat so it works regardless of the system/container TZ. */ export function getTodayLocal(): string { const now = new Date(); const fmt = new Intl.DateTimeFormat("en-US", { timeZone: APP_TIMEZONE, year: "numeric", month: "2-digit", day: "2-digit", hour: "numeric", hour12: false, }); const parts = fmt.formatToParts(now); const hour = parseInt(parts.find((p) => p.type === "hour")!.value, 10) % 24; const target = hour < 3 ? new Date(now.getTime() - 86_400_000) : now; return formatDateTZ(target, APP_TIMEZONE); } /** * Format a Date as YYYY-MM-DD in a specific IANA timezone. */ export function formatDateTZ(d: Date, tz: string): string { const parts = new Intl.DateTimeFormat("en-US", { timeZone: tz, year: "numeric", month: "2-digit", day: "2-digit", }).formatToParts(d); const y = parts.find((p) => p.type === "year")!.value; const m = parts.find((p) => p.type === "month")!.value; const day = parts.find((p) => p.type === "day")!.value; return `${y}-${m}-${day}`; } /** * Format a Date as YYYY-MM-DD using its local (system-timezone) components. * Use this instead of d.toISOString().slice(0,10) which converts to UTC. */ export function formatDateLocal(d: Date): string { const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, "0"); const day = String(d.getDate()).padStart(2, "0"); return `${y}-${m}-${day}`; } /** * Returns the short timezone abbreviation for a given IANA timezone, * e.g. "America/Los_Angeles" → "PDT" or "PST". */ 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 { return getOperatingStatus(hoursLabel, timezone) !== "closed"; } /** * Returns the park's current operating status relative to its scheduled hours: * "open" — within the scheduled open window * "closing" — past scheduled close but within the 1-hour wind-down buffer * "closed" — outside the window entirely * Falls back to "open" when the label can't be parsed. */ export function getOperatingStatus(hoursLabel: string, timezone: string): "open" | "closing" | "closed" { const m = hoursLabel.match( /^(\d+)(?::(\d+))?(am|pm)\s*[–-]\s*(\d+)(?::(\d+))?(am|pm)$/i ); if (!m) return "open"; const toMinutes = (h: string, min: string | undefined, period: string) => { let hours = parseInt(h, 10); const minutes = min ? parseInt(min, 10) : 0; if (period.toLowerCase() === "pm" && hours !== 12) hours += 12; if (period.toLowerCase() === "am" && hours === 12) hours = 0; return hours * 60 + minutes; }; const openMin = toMinutes(m[1], m[2], m[3]); const closeMin = toMinutes(m[4], m[5], m[6]); // 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; if (nowMin >= openMin && nowMin <= closeMin) return "open"; if (nowMin > closeMin && nowMin <= closeMin + 60) return "closing"; return "closed"; }