From 53297a7cff7203d442e718a0634ffbf2b9f28644 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Sun, 5 Apr 2026 09:06:45 -0400 Subject: [PATCH] feat: amber indicator during post-close wind-down buffer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parks in the 1-hour buffer after scheduled close now show amber instead of green: the dot on the desktop calendar turns yellow, and the mobile card badge changes from "Open today" (green) to "Closing" (amber). - getOperatingStatus() replaces isWithinOperatingWindow's inline logic, returning "open" | "closing" | "closed"; isWithinOperatingWindow now delegates to it so all callers are unchanged - closingParkIds[] is computed server-side and threaded through HomePageClient → WeekCalendar/MobileCardList → ParkRow/ParkCard - New --color-closing-* CSS variables mirror the green palette in amber Co-Authored-By: Claude Sonnet 4.6 --- app/globals.css | 6 ++++++ app/page.tsx | 12 +++++++++++- components/HomePageClient.tsx | 4 ++++ components/MobileCardList.tsx | 4 +++- components/ParkCard.tsx | 13 +++++++------ components/WeekCalendar.tsx | 15 +++++++++++---- lib/env.ts | 17 +++++++++++++++-- 7 files changed, 57 insertions(+), 14 deletions(-) diff --git a/app/globals.css b/app/globals.css index 3e04b3c..fb49981 100644 --- a/app/globals.css +++ b/app/globals.css @@ -27,6 +27,12 @@ --color-open-text: #4ade80; --color-open-hours: #bbf7d0; + /* ── Closing — amber (post-close buffer, rides still winding down) ───────── */ + --color-closing-bg: #1a1100; + --color-closing-border: #d97706; + --color-closing-text: #fbbf24; + --color-closing-hours: #fde68a; + /* ── Passholder preview — vivid cyan ─────────────────────────────────────── */ --color-ph-bg: #051518; --color-ph-border: #22d3ee; diff --git a/app/page.tsx b/app/page.tsx index 485d33b..3af8f25 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,7 +1,7 @@ import { HomePageClient } from "@/components/HomePageClient"; import { PARKS } from "@/lib/parks"; import { openDb, getDateRange } from "@/lib/db"; -import { getTodayLocal, isWithinOperatingWindow } from "@/lib/env"; +import { getTodayLocal, isWithinOperatingWindow, getOperatingStatus } from "@/lib/env"; import { fetchLiveRides } from "@/lib/scrapers/queuetimes"; import { QUEUE_TIMES_IDS } from "@/lib/queue-times-map"; import { readParkMeta, getCoasterSet } from "@/lib/park-meta"; @@ -62,12 +62,21 @@ export default async function HomePage({ searchParams }: PageProps) { let rideCounts: Record = {}; let coasterCounts: Record = {}; + let closingParkIds: string[] = []; if (weekDates.includes(today)) { const openTodayParks = PARKS.filter((p) => { const dayData = data[p.id]?.[today]; if (!dayData?.isOpen || !QUEUE_TIMES_IDS[p.id] || !dayData.hoursLabel) return false; return isWithinOperatingWindow(dayData.hoursLabel, p.timezone); }); + closingParkIds = openTodayParks + .filter((p) => { + const dayData = data[p.id]?.[today]; + return dayData?.hoursLabel + ? getOperatingStatus(dayData.hoursLabel, p.timezone) === "closing" + : false; + }) + .map((p) => p.id); const results = await Promise.all( openTodayParks.map(async (p) => { const coasterSet = getCoasterSet(p.id, parkMeta); @@ -94,6 +103,7 @@ export default async function HomePage({ searchParams }: PageProps) { data={data} rideCounts={rideCounts} coasterCounts={coasterCounts} + closingParkIds={closingParkIds} hasCoasterData={hasCoasterData} scrapedCount={scrapedCount} /> diff --git a/components/HomePageClient.tsx b/components/HomePageClient.tsx index ed47f57..37c476c 100644 --- a/components/HomePageClient.tsx +++ b/components/HomePageClient.tsx @@ -19,6 +19,7 @@ interface HomePageClientProps { data: Record>; rideCounts: Record; coasterCounts: Record; + closingParkIds: string[]; hasCoasterData: boolean; scrapedCount: number; } @@ -31,6 +32,7 @@ export function HomePageClient({ data, rideCounts, coasterCounts, + closingParkIds, hasCoasterData, scrapedCount, }: HomePageClientProps) { @@ -165,6 +167,7 @@ export function HomePageClient({ today={today} rideCounts={activeCounts} coastersOnly={coastersOnly} + closingParkIds={closingParkIds} /> @@ -177,6 +180,7 @@ export function HomePageClient({ grouped={grouped} rideCounts={activeCounts} coastersOnly={coastersOnly} + closingParkIds={closingParkIds} /> diff --git a/components/MobileCardList.tsx b/components/MobileCardList.tsx index ffb57bb..a35c1d7 100644 --- a/components/MobileCardList.tsx +++ b/components/MobileCardList.tsx @@ -10,9 +10,10 @@ interface MobileCardListProps { today: string; rideCounts?: Record; coastersOnly?: boolean; + closingParkIds?: string[]; } -export function MobileCardList({ grouped, weekDates, data, today, rideCounts, coastersOnly }: MobileCardListProps) { +export function MobileCardList({ grouped, weekDates, data, today, rideCounts, coastersOnly, closingParkIds }: MobileCardListProps) { return (
{Array.from(grouped.entries()).map(([region, parks]) => ( @@ -54,6 +55,7 @@ export function MobileCardList({ grouped, weekDates, data, today, rideCounts, co today={today} openRideCount={rideCounts?.[park.id]} coastersOnly={coastersOnly} + isClosing={closingParkIds?.includes(park.id)} /> ))}
diff --git a/components/ParkCard.tsx b/components/ParkCard.tsx index 549d85a..b1c83e4 100644 --- a/components/ParkCard.tsx +++ b/components/ParkCard.tsx @@ -10,11 +10,12 @@ interface ParkCardProps { today: string; openRideCount?: number; coastersOnly?: boolean; + isClosing?: boolean; } const DOW = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; -export function ParkCard({ park, weekDates, parkData, today, openRideCount, coastersOnly }: ParkCardProps) { +export function ParkCard({ park, weekDates, parkData, today, openRideCount, coastersOnly, isClosing }: ParkCardProps) { const openDays = weekDates.filter((d) => parkData[d]?.isOpen && parkData[d]?.hoursLabel); const tzAbbr = getTimezoneAbbr(park.timezone); const isOpenToday = openDays.includes(today); @@ -60,17 +61,17 @@ export function ParkCard({ park, weekDates, parkData, today, openRideCount, coas
{isOpenToday ? (
- Open today + {isClosing ? "Closing" : "Open today"}
) : (
diff --git a/components/WeekCalendar.tsx b/components/WeekCalendar.tsx index b4da7a6..e78f984 100644 --- a/components/WeekCalendar.tsx +++ b/components/WeekCalendar.tsx @@ -12,6 +12,7 @@ interface WeekCalendarProps { grouped?: Map; // pre-grouped parks (if provided, renders region headers) rideCounts?: Record; // parkId → open ride/coaster count for today coastersOnly?: boolean; + closingParkIds?: string[]; // parks in the post-close wind-down buffer } const DOW = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; @@ -188,6 +189,7 @@ function ParkRow({ parkData, rideCounts, coastersOnly, + closingParkIds, }: { park: Park; parkIdx: number; @@ -196,9 +198,11 @@ function ParkRow({ parkData: Record; rideCounts?: Record; coastersOnly?: boolean; + closingParkIds?: string[]; }) { const rowBg = parkIdx % 2 === 0 ? "var(--color-bg)" : "var(--color-surface)"; const tzAbbr = getTimezoneAbbr(park.timezone); + const isClosing = closingParkIds?.includes(park.id) ?? false; return ( )}
@@ -245,7 +251,7 @@ function ParkRow({
{rideCounts?.[park.id] !== undefined && ( -
+
{rideCounts[park.id]} {coastersOnly ? (rideCounts[park.id] === 1 ? "coaster" : "coasters") : (rideCounts[park.id] === 1 ? "ride" : "rides")} operating @@ -266,7 +272,7 @@ function ParkRow({ ); } -export function WeekCalendar({ parks, weekDates, data, grouped, rideCounts, coastersOnly }: WeekCalendarProps) { +export function WeekCalendar({ parks, weekDates, data, grouped, rideCounts, coastersOnly, closingParkIds }: WeekCalendarProps) { const today = getTodayLocal(); const parsedDates = weekDates.map(parseDate); @@ -382,6 +388,7 @@ export function WeekCalendar({ parks, weekDates, data, grouped, rideCounts, coas parkData={data[park.id] ?? {}} rideCounts={rideCounts} coastersOnly={coastersOnly} + closingParkIds={closingParkIds} /> ))} diff --git a/lib/env.ts b/lib/env.ts index 7c32382..f56b3c1 100644 --- a/lib/env.ts +++ b/lib/env.ts @@ -55,10 +55,21 @@ export function getTimezoneAbbr(timezone: string): string { * 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 true; + 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; @@ -80,5 +91,7 @@ export function isWithinOperatingWindow(hoursLabel: string, timezone: string): b const min = parseInt(parts.find((p) => p.type === "minute")?.value ?? "0", 10); const nowMin = (h % 24) * 60 + min; - return nowMin >= openMin && nowMin <= closeMin + 60; + if (nowMin >= openMin && nowMin <= closeMin) return "open"; + if (nowMin > closeMin && nowMin <= closeMin + 60) return "closing"; + return "closed"; }