06b911917d
Build and Deploy / Build & Push (push) Successful in 2m50s
getTodayLocal() relied on system clock hours, which broke in the web container (TZ defaulting to UTC) — the day flipped at 11 PM EDT (3 AM UTC) instead of 3 AM Eastern. Now uses Intl.DateTimeFormat with an explicit America/New_York timezone. Also replaced all toISOString() date formatting with local-component helpers to avoid UTC conversion. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
126 lines
4.4 KiB
TypeScript
126 lines
4.4 KiB
TypeScript
/**
|
||
* 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";
|
||
}
|