From 06b911917dd015dadb985057cd831d9503deb7b7 Mon Sep 17 00:00:00 2001 From: josh Date: Thu, 23 Apr 2026 23:11:33 -0400 Subject: [PATCH] fix: use explicit Eastern timezone for day boundary instead of system TZ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/page.tsx | 6 ++-- backend/src/db/queries.ts | 3 +- backend/src/routes/calendar.ts | 6 ++-- components/WeekNav.tsx | 9 ++++- docker-compose.yml | 1 + lib/env.ts | 60 +++++++++++++++++++++++++--------- 6 files changed, 61 insertions(+), 24 deletions(-) diff --git a/app/page.tsx b/app/page.tsx index f1f7fde..14d7545 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,5 +1,5 @@ import { HomePageClient } from "@/components/HomePageClient"; -import { getTodayLocal } from "@/lib/env"; +import { getTodayLocal, formatDateLocal } from "@/lib/env"; const BACKEND_URL = process.env.BACKEND_URL ?? "http://localhost:3001"; @@ -12,13 +12,13 @@ function getWeekStart(param: string | undefined): string { const d = new Date(param + "T00:00:00"); if (!isNaN(d.getTime())) { d.setDate(d.getDate() - d.getDay()); - return d.toISOString().slice(0, 10); + return formatDateLocal(d); } } const todayIso = getTodayLocal(); const d = new Date(todayIso + "T00:00:00"); d.setDate(d.getDate() - d.getDay()); - return d.toISOString().slice(0, 10); + return formatDateLocal(d); } export default async function HomePage({ searchParams }: PageProps) { diff --git a/backend/src/db/queries.ts b/backend/src/db/queries.ts index f5df86a..2c10479 100644 --- a/backend/src/db/queries.ts +++ b/backend/src/db/queries.ts @@ -1,5 +1,6 @@ import type Database from "better-sqlite3"; import { getDb } from "./index"; +import { getTodayLocal } from "../../../lib/env"; import type { DayData } from "../../../lib/types"; export type { DayData }; @@ -126,7 +127,7 @@ export function isMonthScraped( ): boolean { const daysInMonth = new Date(year, month, 0).getDate(); const lastDay = `${year}-${String(month).padStart(2, "0")}-${String(daysInMonth).padStart(2, "0")}`; - const today = new Date().toISOString().slice(0, 10); + const today = getTodayLocal(); if (lastDay < today) return true; diff --git a/backend/src/routes/calendar.ts b/backend/src/routes/calendar.ts index 7e0a726..591c64f 100644 --- a/backend/src/routes/calendar.ts +++ b/backend/src/routes/calendar.ts @@ -2,7 +2,7 @@ import { Hono } from "hono"; import { PARKS } from "../../../lib/parks"; import { QUEUE_TIMES_IDS } from "../../../lib/queue-times-map"; import { getCoasterSet } from "../../../lib/coaster-data"; -import { getTodayLocal, isWithinOperatingWindow, getOperatingStatus } from "../../../lib/env"; +import { getTodayLocal, formatDateLocal, isWithinOperatingWindow, getOperatingStatus } from "../../../lib/env"; import { fetchToday } from "../../../lib/scrapers/sixflags"; import { fetchLiveRides } from "../../../lib/scrapers/queuetimes"; import { getDateRange, getParkMonthData, type DayData } from "../db/queries"; @@ -22,7 +22,7 @@ app.get("/week", async (c) => { const weekDates = Array.from({ length: 7 }, (_, i) => { const d = new Date(startParam + "T00:00:00"); d.setDate(d.getDate() + i); - return d.toISOString().slice(0, 10); + return formatDateLocal(d); }); const endDate = weekDates[6]; const today = getTodayLocal(); @@ -53,7 +53,7 @@ app.get("/week", async (c) => { const currentWeekStart = (() => { const d = new Date(today + "T00:00:00"); d.setDate(d.getDate() - d.getDay()); - return d.toISOString().slice(0, 10); + return formatDateLocal(d); })(); const isCurrentWeek = startParam === currentWeekStart; diff --git a/components/WeekNav.tsx b/components/WeekNav.tsx index 30b6f12..7437658 100644 --- a/components/WeekNav.tsx +++ b/components/WeekNav.tsx @@ -25,10 +25,17 @@ function formatLabel(dates: string[]): string { return `${startStr} – ${endStr}`; } +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}`; +} + function shiftWeek(weekStart: string, delta: number): string { const d = new Date(weekStart + "T00:00:00"); d.setDate(d.getDate() + delta * 7); - return d.toISOString().slice(0, 10); + return formatDateLocal(d); } export function WeekNav({ weekStart, weekDates, isCurrentWeek }: WeekNavProps) { diff --git a/docker-compose.yml b/docker-compose.yml index 88e0065..801f496 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,6 +6,7 @@ services: environment: - NODE_ENV=production - BACKEND_URL=http://backend:3001 + - TZ=America/New_York restart: unless-stopped backend: diff --git a/lib/env.ts b/lib/env.ts index f56b3c1..cf47e3a 100644 --- a/lib/env.ts +++ b/lib/env.ts @@ -12,26 +12,54 @@ export function parseStalenessHours(envVar: string | undefined, defaultHours: nu return Number.isFinite(parsed) && parsed > 0 ? parsed : defaultHours; } +const APP_TIMEZONE = "America/New_York"; + /** - * Returns today's date as YYYY-MM-DD using local wall-clock time with a 3 AM - * switchover. Before 3 AM local time we still consider it "yesterday", so the - * calendar doesn't flip to the next day at midnight while people are still out - * at the park. - * - * Important: `new Date().toISOString()` returns UTC, which causes the date to - * advance at 8 PM EDT (UTC-4) or 7 PM EST (UTC-5) — too early. This helper - * corrects that by using local year/month/day components and rolling back one - * day when the local hour is before 3. + * 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(); - if (now.getHours() < 3) { - now.setDate(now.getDate() - 1); - } - const y = now.getFullYear(); - const m = String(now.getMonth() + 1).padStart(2, "0"); - const d = String(now.getDate()).padStart(2, "0"); - return `${y}-${m}-${d}`; + 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}`; } /**