feat: amber indicator during post-close wind-down buffer
All checks were successful
Build and Deploy / Build & Push (push) Successful in 2m22s
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:
@@ -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;
|
||||||
|
|||||||
12
app/page.tsx
12
app/page.tsx
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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",
|
||||||
}}>
|
}}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
17
lib/env.ts
17
lib/env.ts
@@ -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";
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user