Compare commits

...

4 Commits

Author SHA1 Message Date
fbf4337a83 feat: park page operating window check; always show ride total
All checks were successful
Build and Deploy / Build & Push (push) Successful in 5m54s
- Extract isWithinOperatingWindow() to lib/env.ts (shared)
- Park detail page: always fetch Queue-Times, but force all rides
  closed when outside the ±1h operating window
- LiveRidePanel: always show closed ride count badge (not just when
  some rides are also open); label reads "X rides total" when none
  are open vs "X closed / down" when some are

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 20:43:33 -04:00
8e969165b4 feat: show live open ride count in park name cell
- Fetch Queue-Times ride counts for parks open today (5min cache)
- Only shown within 1h before open to 1h after scheduled close
- Count displayed on the right of the park name/location cell (desktop)
  and below the open badge (mobile)
- Whole park cell is now a clickable link
- Hover warms the park cell background; no row-wide highlight

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 20:38:12 -04:00
43feb4cef0 fix: restrict today highlight to date header only
Remove today background/border from data row cells so the yellow
highlight only appears on the day label, not the entire column.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 20:18:19 -04:00
a87f97ef53 fix: use local time with 3am cutover for today's date
new Date().toISOString() returns UTC, causing the calendar to advance
to the next day at 8pm EDT / 7pm EST. getTodayLocal() reads local
wall-clock time and rolls back one day before 3am so the calendar
stays on the current day through the night.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 20:15:52 -04:00
8 changed files with 158 additions and 52 deletions

View File

@@ -80,20 +80,22 @@
clip-path: inset(0 -16px 0 0); clip-path: inset(0 -16px 0 0);
} }
/* ── Park row hover (group/group-hover via Tailwind not enough across sticky cols) */
.park-row:hover td,
.park-row:hover th {
background-color: var(--color-surface-hover) !important;
}
/* ── Park name link hover ────────────────────────────────────────────────── */ /* ── Park name link hover ────────────────────────────────────────────────── */
.park-name-link { .park-name-link {
text-decoration: none; text-decoration: none;
color: inherit; color: inherit;
transition: color 120ms ease; transition: background 150ms ease;
} }
.park-name-link:hover { .park-name-link:hover {
color: var(--color-accent); background: var(--color-surface-hover);
}
/* ── Mobile park card hover ─────────────────────────────────────────────── */
.park-card {
transition: background 150ms ease;
}
.park-card:hover {
background: var(--color-surface-hover) !important;
} }
/* ── Pulse animation for skeleton ───────────────────────────────────────── */ /* ── Pulse animation for skeleton ───────────────────────────────────────── */

View File

@@ -5,6 +5,9 @@ import { Legend } from "@/components/Legend";
import { EmptyState } from "@/components/EmptyState"; import { EmptyState } from "@/components/EmptyState";
import { PARKS, groupByRegion } from "@/lib/parks"; import { PARKS, groupByRegion } from "@/lib/parks";
import { openDb, getDateRange } from "@/lib/db"; import { openDb, getDateRange } from "@/lib/db";
import { getTodayLocal, isWithinOperatingWindow } from "@/lib/env";
import { fetchLiveRides } from "@/lib/scrapers/queuetimes";
import { QUEUE_TIMES_IDS } from "@/lib/queue-times-map";
interface PageProps { interface PageProps {
searchParams: Promise<{ week?: string }>; searchParams: Promise<{ week?: string }>;
@@ -18,9 +21,10 @@ function getWeekStart(param: string | undefined): string {
return d.toISOString().slice(0, 10); return d.toISOString().slice(0, 10);
} }
} }
const today = new Date(); const todayIso = getTodayLocal();
today.setDate(today.getDate() - today.getDay()); const d = new Date(todayIso + "T00:00:00");
return today.toISOString().slice(0, 10); d.setDate(d.getDate() - d.getDay());
return d.toISOString().slice(0, 10);
} }
function getWeekDates(sundayIso: string): string[] { function getWeekDates(sundayIso: string): string[] {
@@ -32,9 +36,10 @@ function getWeekDates(sundayIso: string): string[] {
} }
function getCurrentWeekStart(): string { function getCurrentWeekStart(): string {
const today = new Date(); const todayIso = getTodayLocal();
today.setDate(today.getDate() - today.getDay()); const d = new Date(todayIso + "T00:00:00");
return today.toISOString().slice(0, 10); d.setDate(d.getDate() - d.getDay());
return d.toISOString().slice(0, 10);
} }
export default async function HomePage({ searchParams }: PageProps) { export default async function HomePage({ searchParams }: PageProps) {
@@ -42,7 +47,7 @@ export default async function HomePage({ searchParams }: PageProps) {
const weekStart = getWeekStart(params.week); const weekStart = getWeekStart(params.week);
const weekDates = getWeekDates(weekStart); const weekDates = getWeekDates(weekStart);
const endDate = weekDates[6]; const endDate = weekDates[6];
const today = new Date().toISOString().slice(0, 10); const today = getTodayLocal();
const isCurrentWeek = weekStart === getCurrentWeekStart(); const isCurrentWeek = weekStart === getCurrentWeekStart();
const db = openDb(); const db = openDb();
@@ -54,6 +59,25 @@ export default async function HomePage({ searchParams }: PageProps) {
0 0
); );
// Fetch live ride counts for parks open today (cached 5 min via Queue-Times).
// Only shown when the current time is within 1h before open to 1h after close.
let rideCounts: Record<string, number> = {};
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);
});
const results = await Promise.all(
openTodayParks.map(async (p) => {
const result = await fetchLiveRides(QUEUE_TIMES_IDS[p.id], null, 300);
const count = result ? result.rides.filter((r) => r.isOpen).length : 0;
return [p.id, count] as [string, number];
})
);
rideCounts = Object.fromEntries(results.filter(([, count]) => count > 0));
}
const visibleParks = PARKS.filter((park) => const visibleParks = PARKS.filter((park) =>
weekDates.some((date) => data[park.id]?.[date]?.isOpen) weekDates.some((date) => data[park.id]?.[date]?.isOpen)
); );
@@ -133,6 +157,7 @@ export default async function HomePage({ searchParams }: PageProps) {
weekDates={weekDates} weekDates={weekDates}
data={data} data={data}
today={today} today={today}
rideCounts={rideCounts}
/> />
</div> </div>
@@ -143,6 +168,7 @@ export default async function HomePage({ searchParams }: PageProps) {
weekDates={weekDates} weekDates={weekDates}
data={data} data={data}
grouped={grouped} grouped={grouped}
rideCounts={rideCounts}
/> />
</div> </div>
</> </>

View File

@@ -10,6 +10,7 @@ import { ParkMonthCalendar } from "@/components/ParkMonthCalendar";
import { LiveRidePanel } from "@/components/LiveRidePanel"; import { LiveRidePanel } from "@/components/LiveRidePanel";
import type { RideStatus, RidesFetchResult } from "@/lib/scrapers/sixflags"; import type { RideStatus, RidesFetchResult } from "@/lib/scrapers/sixflags";
import type { LiveRidesResult } from "@/lib/scrapers/queuetimes"; // used as prop type below import type { LiveRidesResult } from "@/lib/scrapers/queuetimes"; // used as prop type below
import { getTodayLocal, isWithinOperatingWindow } from "@/lib/env";
interface PageProps { interface PageProps {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
@@ -23,8 +24,8 @@ function parseMonthParam(param: string | undefined): { year: number; month: numb
return { year: y, month: m }; return { year: y, month: m };
} }
} }
const now = new Date(); const [y, m] = getTodayLocal().split("-").map(Number);
return { year: now.getFullYear(), month: now.getMonth() + 1 }; return { year: y, month: m };
} }
export default async function ParkPage({ params, searchParams }: PageProps) { export default async function ParkPage({ params, searchParams }: PageProps) {
@@ -34,7 +35,7 @@ export default async function ParkPage({ params, searchParams }: PageProps) {
const park = PARK_MAP.get(id); const park = PARK_MAP.get(id);
if (!park) notFound(); if (!park) notFound();
const today = new Date().toISOString().slice(0, 10); const today = getTodayLocal();
const { year, month } = parseMonthParam(monthParam); const { year, month } = parseMonthParam(monthParam);
const db = openDb(); const db = openDb();
@@ -53,8 +54,22 @@ export default async function ParkPage({ params, searchParams }: PageProps) {
let liveRides: LiveRidesResult | null = null; let liveRides: LiveRidesResult | null = null;
let ridesResult: RidesFetchResult | null = null; let ridesResult: RidesFetchResult | null = null;
// Determine if we're within the 1h-before-open to 1h-after-close window.
const withinWindow = todayData?.hoursLabel
? isWithinOperatingWindow(todayData.hoursLabel)
: false;
if (queueTimesId) { if (queueTimesId) {
liveRides = await fetchLiveRides(queueTimesId, coasterSet); const raw = await fetchLiveRides(queueTimesId, coasterSet);
if (raw) {
// Outside the window: show the ride list but force all rides closed
liveRides = withinWindow
? raw
: {
...raw,
rides: raw.rides.map((r) => ({ ...r, isOpen: false, waitMinutes: 0 })),
};
}
} }
// Only hit the schedule API as a fallback when live data is unavailable // Only hit the schedule API as a fallback when live data is unavailable

View File

@@ -57,8 +57,8 @@ export function LiveRidePanel({ liveRides, parkOpenToday }: LiveRidePanelProps)
</div> </div>
)} )}
{/* Closed count badge */} {/* Closed count badge — always shown when there are closed rides */}
{anyOpen && closedRides.length > 0 && ( {closedRides.length > 0 && (
<div style={{ <div style={{
background: "var(--color-surface)", background: "var(--color-surface)",
border: "1px solid var(--color-border)", border: "1px solid var(--color-border)",
@@ -69,7 +69,7 @@ export function LiveRidePanel({ liveRides, parkOpenToday }: LiveRidePanelProps)
color: "var(--color-text-muted)", color: "var(--color-text-muted)",
flexShrink: 0, flexShrink: 0,
}}> }}>
{closedRides.length} closed / down {closedRides.length} {anyOpen ? "closed / down" : "rides total"}
</div> </div>
)} )}

View File

@@ -8,9 +8,10 @@ interface MobileCardListProps {
weekDates: string[]; weekDates: string[];
data: Record<string, Record<string, DayData>>; data: Record<string, Record<string, DayData>>;
today: string; today: string;
rideCounts?: Record<string, number>;
} }
export function MobileCardList({ grouped, weekDates, data, today }: MobileCardListProps) { export function MobileCardList({ grouped, weekDates, data, today, rideCounts }: 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]) => (
@@ -50,6 +51,7 @@ export function MobileCardList({ grouped, weekDates, data, today }: MobileCardLi
weekDates={weekDates} weekDates={weekDates}
parkData={data[park.id] ?? {}} parkData={data[park.id] ?? {}}
today={today} today={today}
openRideCount={rideCounts?.[park.id]}
/> />
))} ))}
</div> </div>

View File

@@ -7,11 +7,12 @@ interface ParkCardProps {
weekDates: string[]; // 7 dates YYYY-MM-DD, SunSat weekDates: string[]; // 7 dates YYYY-MM-DD, SunSat
parkData: Record<string, DayData>; parkData: Record<string, DayData>;
today: string; today: string;
openRideCount?: number;
} }
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 }: ParkCardProps) { export function ParkCard({ park, weekDates, parkData, today, openRideCount }: ParkCardProps) {
const openDays = weekDates.filter((d) => parkData[d]?.isOpen && parkData[d]?.hoursLabel); const openDays = weekDates.filter((d) => parkData[d]?.isOpen && parkData[d]?.hoursLabel);
const isOpenToday = openDays.includes(today); const isOpenToday = openDays.includes(today);
@@ -21,12 +22,11 @@ export function ParkCard({ park, weekDates, parkData, today }: ParkCardProps) {
data-park={park.name.toLowerCase()} data-park={park.name.toLowerCase()}
style={{ textDecoration: "none", display: "block" }} style={{ textDecoration: "none", display: "block" }}
> >
<div style={{ <div className="park-card" style={{
background: "var(--color-surface)", background: "var(--color-surface)",
border: "1px solid var(--color-border)", border: "1px solid var(--color-border)",
borderRadius: 12, borderRadius: 12,
overflow: "hidden", overflow: "hidden",
transition: "border-color 120ms ease",
}}> }}>
{/* ── Card header ───────────────────────────────────────────────────── */} {/* ── Card header ───────────────────────────────────────────────────── */}
<div style={{ <div style={{
@@ -54,6 +54,7 @@ export function ParkCard({ park, weekDates, parkData, today }: ParkCardProps) {
</div> </div>
</div> </div>
<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: "var(--color-open-bg)",
@@ -64,7 +65,6 @@ export function ParkCard({ park, weekDates, parkData, today }: ParkCardProps) {
fontWeight: 700, fontWeight: 700,
color: "var(--color-open-text)", color: "var(--color-open-text)",
whiteSpace: "nowrap", whiteSpace: "nowrap",
flexShrink: 0,
letterSpacing: "0.03em", letterSpacing: "0.03em",
}}> }}>
Open today Open today
@@ -79,11 +79,21 @@ export function ParkCard({ park, weekDates, parkData, today }: ParkCardProps) {
fontWeight: 500, fontWeight: 500,
color: "var(--color-text-muted)", color: "var(--color-text-muted)",
whiteSpace: "nowrap", whiteSpace: "nowrap",
flexShrink: 0,
}}> }}>
Closed today Closed today
</div> </div>
)} )}
{isOpenToday && openRideCount !== undefined && (
<div style={{
fontSize: "0.65rem",
color: "var(--color-open-hours)",
fontWeight: 500,
textAlign: "right",
}}>
{openRideCount} open
</div>
)}
</div>
</div> </div>
{/* ── Open days list ────────────────────────────────────────────────── */} {/* ── Open days list ────────────────────────────────────────────────── */}

View File

@@ -3,12 +3,14 @@ import Link from "next/link";
import type { Park } from "@/lib/scrapers/types"; import type { Park } from "@/lib/scrapers/types";
import type { DayData } from "@/lib/db"; import type { DayData } from "@/lib/db";
import type { Region } from "@/lib/parks"; import type { Region } from "@/lib/parks";
import { getTodayLocal } from "@/lib/env";
interface WeekCalendarProps { interface WeekCalendarProps {
parks: Park[]; parks: Park[];
weekDates: string[]; // 7 dates, YYYY-MM-DD, SunSat weekDates: string[]; // 7 dates, YYYY-MM-DD, SunSat
data: Record<string, Record<string, DayData>>; // parkId → date → DayData data: Record<string, Record<string, DayData>>; // parkId → date → DayData
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 count for today
} }
const DOW = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; const DOW = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
@@ -29,11 +31,9 @@ function parseDate(iso: string) {
function DayCell({ function DayCell({
dayData, dayData,
isToday,
isWeekend, isWeekend,
}: { }: {
dayData: DayData | undefined; dayData: DayData | undefined;
isToday: boolean;
isWeekend: boolean; isWeekend: boolean;
}) { }) {
const base: React.CSSProperties = { const base: React.CSSProperties = {
@@ -43,14 +43,7 @@ function DayCell({
borderBottom: "1px solid var(--color-border)", borderBottom: "1px solid var(--color-border)",
borderLeft: "1px solid var(--color-border)", borderLeft: "1px solid var(--color-border)",
height: 56, height: 56,
background: isToday background: isWeekend ? "var(--color-weekend-header)" : "transparent",
? "var(--color-today-bg)"
: isWeekend
? "var(--color-weekend-header)"
: "transparent",
borderLeftColor: isToday ? "var(--color-today-border)" : undefined,
borderRightColor: isToday ? "var(--color-today-border)" : undefined,
borderRight: isToday ? "1px solid var(--color-today-border)" : undefined,
transition: "background 120ms ease", transition: "background 120ms ease",
}; };
@@ -171,14 +164,14 @@ function ParkRow({
weekDates, weekDates,
parsedDates, parsedDates,
parkData, parkData,
today, rideCounts,
}: { }: {
park: Park; park: Park;
parkIdx: number; parkIdx: number;
weekDates: string[]; weekDates: string[];
parsedDates: ReturnType<typeof parseDate>[]; parsedDates: ReturnType<typeof parseDate>[];
parkData: Record<string, DayData>; parkData: Record<string, DayData>;
today: string; rideCounts?: Record<string, number>;
}) { }) {
const rowBg = parkIdx % 2 === 0 ? "var(--color-bg)" : "var(--color-surface)"; const rowBg = parkIdx % 2 === 0 ? "var(--color-bg)" : "var(--color-surface)";
return ( return (
@@ -191,7 +184,7 @@ function ParkRow({
position: "sticky", position: "sticky",
left: 0, left: 0,
zIndex: 5, zIndex: 5,
padding: "10px 14px", padding: 0,
borderBottom: "1px solid var(--color-border)", borderBottom: "1px solid var(--color-border)",
borderRight: "1px solid var(--color-border)", borderRight: "1px solid var(--color-border)",
whiteSpace: "nowrap", whiteSpace: "nowrap",
@@ -199,21 +192,33 @@ function ParkRow({
background: rowBg, background: rowBg,
transition: "background 120ms ease", transition: "background 120ms ease",
}}> }}>
<Link href={`/park/${park.id}`} className="park-name-link"> <Link href={`/park/${park.id}`} className="park-name-link" style={{
<span style={{ fontWeight: 500, fontSize: "0.85rem", lineHeight: 1.2 }}> display: "flex",
{park.name} alignItems: "center",
</span> justifyContent: "space-between",
padding: "10px 14px",
gap: 10,
}}>
<div>
<span style={{ fontWeight: 500, fontSize: "0.85rem", lineHeight: 1.2, color: "var(--color-text)" }}>
{park.name}
</span>
<div style={{ fontSize: "0.7rem", color: "var(--color-text-muted)", marginTop: 2 }}>
{park.location.city}, {park.location.state}
</div>
</div>
{rideCounts?.[park.id] !== undefined && (
<div style={{ fontSize: "0.65rem", color: "var(--color-open-hours)", fontWeight: 500, whiteSpace: "nowrap", flexShrink: 0 }}>
{rideCounts[park.id]} open
</div>
)}
</Link> </Link>
<div style={{ fontSize: "0.7rem", color: "var(--color-text-muted)", marginTop: 2 }}>
{park.location.city}, {park.location.state}
</div>
</td> </td>
{weekDates.map((date, i) => ( {weekDates.map((date, i) => (
<DayCell <DayCell
key={date} key={date}
dayData={parkData[date]} dayData={parkData[date]}
isToday={date === today}
isWeekend={parsedDates[i].isWeekend} isWeekend={parsedDates[i].isWeekend}
/> />
))} ))}
@@ -221,8 +226,8 @@ function ParkRow({
); );
} }
export function WeekCalendar({ parks, weekDates, data, grouped }: WeekCalendarProps) { export function WeekCalendar({ parks, weekDates, data, grouped, rideCounts }: WeekCalendarProps) {
const today = new Date().toISOString().slice(0, 10); const today = getTodayLocal();
const parsedDates = weekDates.map(parseDate); const parsedDates = weekDates.map(parseDate);
const firstMonth = parsedDates[0].month; const firstMonth = parsedDates[0].month;
@@ -335,7 +340,7 @@ export function WeekCalendar({ parks, weekDates, data, grouped }: WeekCalendarPr
weekDates={weekDates} weekDates={weekDates}
parsedDates={parsedDates} parsedDates={parsedDates}
parkData={data[park.id] ?? {}} parkData={data[park.id] ?? {}}
today={today} rideCounts={rideCounts}
/> />
))} ))}
</Fragment> </Fragment>

View File

@@ -11,3 +11,49 @@ export function parseStalenessHours(envVar: string | undefined, defaultHours: nu
const parsed = parseInt(envVar ?? "", 10); const parsed = parseInt(envVar ?? "", 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : defaultHours; return Number.isFinite(parsed) && parsed > 0 ? parsed : defaultHours;
} }
/**
* 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.
*/
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}`;
}
/**
* Returns true when the current local time is within 1 hour before open
* or 1 hour after close, based on a hoursLabel like "10am 6pm".
* Falls back to true when the label can't be parsed.
*/
export function isWithinOperatingWindow(hoursLabel: string): boolean {
const m = hoursLabel.match(
/^(\d+)(?::(\d+))?(am|pm)\s*[-]\s*(\d+)(?::(\d+))?(am|pm)$/i
);
if (!m) return true;
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]);
const now = new Date();
const nowMin = now.getHours() * 60 + now.getMinutes();
return nowMin >= openMin - 60 && nowMin <= closeMin + 60;
}