feat: amber indicator during post-close wind-down buffer
All checks were successful
Build and Deploy / Build & Push (push) Successful in 2m22s

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 <noreply@anthropic.com>
This commit is contained in:
Josh Wright
2026-04-05 09:06:45 -04:00
parent 090f4d876d
commit 53297a7cff
7 changed files with 57 additions and 14 deletions

View File

@@ -27,6 +27,12 @@
--color-open-text: #4ade80; --color-open-text: #4ade80;
--color-open-hours: #bbf7d0; --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 ─────────────────────────────────────── */ /* ── Passholder preview — vivid cyan ─────────────────────────────────────── */
--color-ph-bg: #051518; --color-ph-bg: #051518;
--color-ph-border: #22d3ee; --color-ph-border: #22d3ee;

View File

@@ -1,7 +1,7 @@
import { HomePageClient } from "@/components/HomePageClient"; import { HomePageClient } from "@/components/HomePageClient";
import { PARKS } from "@/lib/parks"; import { PARKS } from "@/lib/parks";
import { openDb, getDateRange } from "@/lib/db"; 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 { fetchLiveRides } from "@/lib/scrapers/queuetimes";
import { QUEUE_TIMES_IDS } from "@/lib/queue-times-map"; import { QUEUE_TIMES_IDS } from "@/lib/queue-times-map";
import { readParkMeta, getCoasterSet } from "@/lib/park-meta"; import { readParkMeta, getCoasterSet } from "@/lib/park-meta";
@@ -62,12 +62,21 @@ export default async function HomePage({ searchParams }: PageProps) {
let rideCounts: Record<string, number> = {}; let rideCounts: Record<string, number> = {};
let coasterCounts: Record<string, number> = {}; let coasterCounts: Record<string, number> = {};
let closingParkIds: string[] = [];
if (weekDates.includes(today)) { if (weekDates.includes(today)) {
const openTodayParks = PARKS.filter((p) => { const openTodayParks = PARKS.filter((p) => {
const dayData = data[p.id]?.[today]; const dayData = data[p.id]?.[today];
if (!dayData?.isOpen || !QUEUE_TIMES_IDS[p.id] || !dayData.hoursLabel) return false; if (!dayData?.isOpen || !QUEUE_TIMES_IDS[p.id] || !dayData.hoursLabel) return false;
return isWithinOperatingWindow(dayData.hoursLabel, p.timezone); 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( const results = await Promise.all(
openTodayParks.map(async (p) => { openTodayParks.map(async (p) => {
const coasterSet = getCoasterSet(p.id, parkMeta); const coasterSet = getCoasterSet(p.id, parkMeta);
@@ -94,6 +103,7 @@ export default async function HomePage({ searchParams }: PageProps) {
data={data} data={data}
rideCounts={rideCounts} rideCounts={rideCounts}
coasterCounts={coasterCounts} coasterCounts={coasterCounts}
closingParkIds={closingParkIds}
hasCoasterData={hasCoasterData} hasCoasterData={hasCoasterData}
scrapedCount={scrapedCount} scrapedCount={scrapedCount}
/> />

View File

@@ -19,6 +19,7 @@ interface HomePageClientProps {
data: Record<string, Record<string, DayData>>; data: Record<string, Record<string, DayData>>;
rideCounts: Record<string, number>; rideCounts: Record<string, number>;
coasterCounts: Record<string, number>; coasterCounts: Record<string, number>;
closingParkIds: string[];
hasCoasterData: boolean; hasCoasterData: boolean;
scrapedCount: number; scrapedCount: number;
} }
@@ -31,6 +32,7 @@ export function HomePageClient({
data, data,
rideCounts, rideCounts,
coasterCounts, coasterCounts,
closingParkIds,
hasCoasterData, hasCoasterData,
scrapedCount, scrapedCount,
}: HomePageClientProps) { }: HomePageClientProps) {
@@ -165,6 +167,7 @@ export function HomePageClient({
today={today} today={today}
rideCounts={activeCounts} rideCounts={activeCounts}
coastersOnly={coastersOnly} coastersOnly={coastersOnly}
closingParkIds={closingParkIds}
/> />
</div> </div>
@@ -177,6 +180,7 @@ export function HomePageClient({
grouped={grouped} grouped={grouped}
rideCounts={activeCounts} rideCounts={activeCounts}
coastersOnly={coastersOnly} coastersOnly={coastersOnly}
closingParkIds={closingParkIds}
/> />
</div> </div>
</> </>

View File

@@ -10,9 +10,10 @@ interface MobileCardListProps {
today: string; today: string;
rideCounts?: Record<string, number>; rideCounts?: Record<string, number>;
coastersOnly?: boolean; 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 ( return (
<div style={{ display: "flex", flexDirection: "column", gap: 20, paddingTop: 14 }}> <div style={{ display: "flex", flexDirection: "column", gap: 20, paddingTop: 14 }}>
{Array.from(grouped.entries()).map(([region, parks]) => ( {Array.from(grouped.entries()).map(([region, parks]) => (
@@ -54,6 +55,7 @@ export function MobileCardList({ grouped, weekDates, data, today, rideCounts, co
today={today} today={today}
openRideCount={rideCounts?.[park.id]} openRideCount={rideCounts?.[park.id]}
coastersOnly={coastersOnly} coastersOnly={coastersOnly}
isClosing={closingParkIds?.includes(park.id)}
/> />
))} ))}
</div> </div>

View File

@@ -10,11 +10,12 @@ interface ParkCardProps {
today: string; today: string;
openRideCount?: number; openRideCount?: number;
coastersOnly?: boolean; coastersOnly?: boolean;
isClosing?: boolean;
} }
const DOW = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; 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 openDays = weekDates.filter((d) => parkData[d]?.isOpen && parkData[d]?.hoursLabel);
const tzAbbr = getTimezoneAbbr(park.timezone); const tzAbbr = getTimezoneAbbr(park.timezone);
const isOpenToday = openDays.includes(today); const isOpenToday = openDays.includes(today);
@@ -60,17 +61,17 @@ export function ParkCard({ park, weekDates, parkData, today, openRideCount, coas
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 5, flexShrink: 0 }}> <div style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 5, flexShrink: 0 }}>
{isOpenToday ? ( {isOpenToday ? (
<div style={{ <div style={{
background: "var(--color-open-bg)", background: isClosing ? "var(--color-closing-bg)" : "var(--color-open-bg)",
border: "1px solid var(--color-open-border)", border: `1px solid ${isClosing ? "var(--color-closing-border)" : "var(--color-open-border)"}`,
borderRadius: 20, borderRadius: 20,
padding: "4px 10px", padding: "4px 10px",
fontSize: "0.65rem", fontSize: "0.65rem",
fontWeight: 700, fontWeight: 700,
color: "var(--color-open-text)", color: isClosing ? "var(--color-closing-text)" : "var(--color-open-text)",
whiteSpace: "nowrap", whiteSpace: "nowrap",
letterSpacing: "0.03em", letterSpacing: "0.03em",
}}> }}>
Open today {isClosing ? "Closing" : "Open today"}
</div> </div>
) : ( ) : (
<div style={{ <div style={{
@@ -89,7 +90,7 @@ export function ParkCard({ park, weekDates, parkData, today, openRideCount, coas
{isOpenToday && openRideCount !== undefined && ( {isOpenToday && openRideCount !== undefined && (
<div style={{ <div style={{
fontSize: "0.65rem", fontSize: "0.65rem",
color: "var(--color-open-hours)", color: isClosing ? "var(--color-closing-hours)" : "var(--color-open-hours)",
fontWeight: 500, fontWeight: 500,
textAlign: "right", textAlign: "right",
}}> }}>

View File

@@ -12,6 +12,7 @@ interface WeekCalendarProps {
grouped?: Map<Region, Park[]>; // pre-grouped parks (if provided, renders region headers) grouped?: Map<Region, Park[]>; // pre-grouped parks (if provided, renders region headers)
rideCounts?: Record<string, number>; // parkId → open ride/coaster count for today rideCounts?: Record<string, number>; // parkId → open ride/coaster count for today
coastersOnly?: boolean; coastersOnly?: boolean;
closingParkIds?: string[]; // parks in the post-close wind-down buffer
} }
const DOW = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; const DOW = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
@@ -188,6 +189,7 @@ function ParkRow({
parkData, parkData,
rideCounts, rideCounts,
coastersOnly, coastersOnly,
closingParkIds,
}: { }: {
park: Park; park: Park;
parkIdx: number; parkIdx: number;
@@ -196,9 +198,11 @@ function ParkRow({
parkData: Record<string, DayData>; parkData: Record<string, DayData>;
rideCounts?: Record<string, number>; rideCounts?: Record<string, number>;
coastersOnly?: boolean; coastersOnly?: boolean;
closingParkIds?: string[];
}) { }) {
const rowBg = parkIdx % 2 === 0 ? "var(--color-bg)" : "var(--color-surface)"; const rowBg = parkIdx % 2 === 0 ? "var(--color-bg)" : "var(--color-surface)";
const tzAbbr = getTimezoneAbbr(park.timezone); const tzAbbr = getTimezoneAbbr(park.timezone);
const isClosing = closingParkIds?.includes(park.id) ?? false;
return ( return (
<tr <tr
className="park-row" className="park-row"
@@ -234,9 +238,11 @@ function ParkRow({
width: 7, width: 7,
height: 7, height: 7,
borderRadius: "50%", borderRadius: "50%",
background: "var(--color-open-text)", background: isClosing ? "var(--color-closing-text)" : "var(--color-open-text)",
flexShrink: 0, flexShrink: 0,
boxShadow: "0 0 5px var(--color-open-text)", boxShadow: isClosing
? "0 0 5px var(--color-closing-text)"
: "0 0 5px var(--color-open-text)",
}} /> }} />
)} )}
</div> </div>
@@ -245,7 +251,7 @@ function ParkRow({
</div> </div>
</div> </div>
{rideCounts?.[park.id] !== undefined && ( {rideCounts?.[park.id] !== undefined && (
<div style={{ fontSize: "0.72rem", color: "var(--color-open-hours)", fontWeight: 600, whiteSpace: "nowrap", flexShrink: 0 }}> <div style={{ fontSize: "0.72rem", color: isClosing ? "var(--color-closing-hours)" : "var(--color-open-hours)", fontWeight: 600, whiteSpace: "nowrap", flexShrink: 0 }}>
{rideCounts[park.id]} {coastersOnly {rideCounts[park.id]} {coastersOnly
? (rideCounts[park.id] === 1 ? "coaster" : "coasters") ? (rideCounts[park.id] === 1 ? "coaster" : "coasters")
: (rideCounts[park.id] === 1 ? "ride" : "rides")} operating : (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 today = getTodayLocal();
const parsedDates = weekDates.map(parseDate); const parsedDates = weekDates.map(parseDate);
@@ -382,6 +388,7 @@ export function WeekCalendar({ parks, weekDates, data, grouped, rideCounts, coas
parkData={data[park.id] ?? {}} parkData={data[park.id] ?? {}}
rideCounts={rideCounts} rideCounts={rideCounts}
coastersOnly={coastersOnly} coastersOnly={coastersOnly}
closingParkIds={closingParkIds}
/> />
))} ))}
</Fragment> </Fragment>

View File

@@ -55,10 +55,21 @@ export function getTimezoneAbbr(timezone: string): string {
* compared to Pacific time regardless of where the server is running. * compared to Pacific time regardless of where the server is running.
*/ */
export function isWithinOperatingWindow(hoursLabel: string, timezone: string): boolean { 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( const m = hoursLabel.match(
/^(\d+)(?::(\d+))?(am|pm)\s*[-]\s*(\d+)(?::(\d+))?(am|pm)$/i /^(\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) => { const toMinutes = (h: string, min: string | undefined, period: string) => {
let hours = parseInt(h, 10); let hours = parseInt(h, 10);
const minutes = min ? parseInt(min, 10) : 0; 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 min = parseInt(parts.find((p) => p.type === "minute")?.value ?? "0", 10);
const nowMin = (h % 24) * 60 + min; 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";
} }