Compare commits
35 Commits
7ee28c7ca3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0009af751f | |||
| 4063ded9ec | |||
|
|
f0faff412c | ||
|
|
08db97faa8 | ||
|
|
054c82529b | ||
|
|
8437cadee0 | ||
|
|
b4af83b879 | ||
|
|
b1204c95cb | ||
|
|
a5b98f93e6 | ||
|
|
b2ef342bf4 | ||
|
|
e405170c8b | ||
|
|
fd99f6f390 | ||
|
|
4e6040a781 | ||
|
|
7904475ddc | ||
|
|
a84bbcac31 | ||
|
|
569d0a41e2 | ||
|
|
c6c32a168b | ||
|
|
cba8218fe8 | ||
|
|
695feff443 | ||
|
|
f85cc084b7 | ||
|
|
32f0d05038 | ||
|
|
d84a15ad64 | ||
|
|
b26382f427 | ||
|
|
56c7b90262 | ||
|
|
5e4dd7403e | ||
|
|
a717e122f0 | ||
|
|
732390425f | ||
|
|
a1694668d9 | ||
|
|
f809f9171b | ||
|
|
fa269db3ef | ||
|
|
ef3e57bd5a | ||
|
|
ed6d09f3bc | ||
|
|
e2498af481 | ||
|
|
d7f046a4d6 | ||
|
|
7c00ae5000 |
@@ -27,6 +27,12 @@
|
||||
--color-open-text: #4ade80;
|
||||
--color-open-hours: #bbf7d0;
|
||||
|
||||
/* ── Weather delay — blue (open by schedule but all rides closed) ───────── */
|
||||
--color-weather-bg: #0a1020;
|
||||
--color-weather-border: #3b82f6;
|
||||
--color-weather-text: #60a5fa;
|
||||
--color-weather-hours: #bfdbfe;
|
||||
|
||||
/* ── Closing — amber (post-close buffer, rides still winding down) ───────── */
|
||||
--color-closing-bg: #1a1100;
|
||||
--color-closing-border: #d97706;
|
||||
@@ -112,7 +118,7 @@
|
||||
/* sm+: let rows breathe and grow with their content (cells are wide enough) */
|
||||
@media (min-width: 640px) {
|
||||
.park-calendar-grid {
|
||||
grid-auto-rows: minmax(96px, auto);
|
||||
grid-auto-rows: minmax(108px, auto);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
49
app/page.tsx
49
app/page.tsx
@@ -1,10 +1,12 @@
|
||||
import { HomePageClient } from "@/components/HomePageClient";
|
||||
import { PARKS } from "@/lib/parks";
|
||||
import { openDb, getDateRange } from "@/lib/db";
|
||||
import { openDb, getDateRange, getApiId } from "@/lib/db";
|
||||
import { getTodayLocal, isWithinOperatingWindow, getOperatingStatus } from "@/lib/env";
|
||||
import { fetchLiveRides } from "@/lib/scrapers/queuetimes";
|
||||
import { fetchToday } from "@/lib/scrapers/sixflags";
|
||||
import { QUEUE_TIMES_IDS } from "@/lib/queue-times-map";
|
||||
import { readParkMeta, getCoasterSet } from "@/lib/park-meta";
|
||||
import type { DayData } from "@/lib/db";
|
||||
|
||||
interface PageProps {
|
||||
searchParams: Promise<{ week?: string }>;
|
||||
@@ -49,6 +51,31 @@ export default async function HomePage({ searchParams }: PageProps) {
|
||||
|
||||
const db = openDb();
|
||||
const data = getDateRange(db, weekStart, endDate);
|
||||
|
||||
// Merge live today data from the Six Flags API (dateless endpoint, 5-min ISR cache).
|
||||
// This ensures weather delays, early closures, and hour changes surface within 5 minutes
|
||||
// without waiting for the next scheduled scrape. Only fetched when viewing the current week.
|
||||
if (weekDates.includes(today)) {
|
||||
const todayResults = await Promise.all(
|
||||
PARKS.map(async (p) => {
|
||||
const apiId = getApiId(db, p.id);
|
||||
if (!apiId) return null;
|
||||
const live = await fetchToday(apiId, 300); // 5-min ISR cache
|
||||
return live ? { parkId: p.id, live } : null;
|
||||
})
|
||||
);
|
||||
for (const result of todayResults) {
|
||||
if (!result) continue;
|
||||
const { parkId, live } = result;
|
||||
if (!data[parkId]) data[parkId] = {};
|
||||
data[parkId][today] = {
|
||||
isOpen: live.isOpen,
|
||||
hoursLabel: live.hoursLabel ?? null,
|
||||
specialType: live.specialType ?? null,
|
||||
} satisfies DayData;
|
||||
}
|
||||
}
|
||||
|
||||
db.close();
|
||||
|
||||
const scrapedCount = Object.values(data).reduce(
|
||||
@@ -63,12 +90,16 @@ export default async function HomePage({ searchParams }: PageProps) {
|
||||
let rideCounts: Record<string, number> = {};
|
||||
let coasterCounts: Record<string, number> = {};
|
||||
let closingParkIds: string[] = [];
|
||||
let openParkIds: string[] = [];
|
||||
let weatherDelayParkIds: string[] = [];
|
||||
if (weekDates.includes(today)) {
|
||||
// Parks within operating hours right now (for open dot — independent of ride counts)
|
||||
const openTodayParks = PARKS.filter((p) => {
|
||||
const dayData = data[p.id]?.[today];
|
||||
if (!dayData?.isOpen || !QUEUE_TIMES_IDS[p.id] || !dayData.hoursLabel) return false;
|
||||
if (!dayData?.isOpen || !dayData.hoursLabel) return false;
|
||||
return isWithinOperatingWindow(dayData.hoursLabel, p.timezone);
|
||||
});
|
||||
openParkIds = openTodayParks.map((p) => p.id);
|
||||
closingParkIds = openTodayParks
|
||||
.filter((p) => {
|
||||
const dayData = data[p.id]?.[today];
|
||||
@@ -77,17 +108,23 @@ export default async function HomePage({ searchParams }: PageProps) {
|
||||
: false;
|
||||
})
|
||||
.map((p) => p.id);
|
||||
// Only fetch ride counts for parks that have queue-times coverage
|
||||
const trackedParks = openTodayParks.filter((p) => QUEUE_TIMES_IDS[p.id]);
|
||||
const results = await Promise.all(
|
||||
openTodayParks.map(async (p) => {
|
||||
trackedParks.map(async (p) => {
|
||||
const coasterSet = getCoasterSet(p.id, parkMeta);
|
||||
const result = await fetchLiveRides(QUEUE_TIMES_IDS[p.id], coasterSet, 300);
|
||||
const rideCount = result ? result.rides.filter((r) => r.isOpen).length : 0;
|
||||
const rideCount = result ? result.rides.filter((r) => r.isOpen).length : null;
|
||||
const coasterCount = result ? result.rides.filter((r) => r.isOpen && r.isCoaster).length : 0;
|
||||
return { id: p.id, rideCount, coasterCount };
|
||||
})
|
||||
);
|
||||
// Parks with queue-times coverage but 0 open rides = likely weather delay
|
||||
weatherDelayParkIds = results
|
||||
.filter(({ rideCount }) => rideCount === 0)
|
||||
.map(({ id }) => id);
|
||||
rideCounts = Object.fromEntries(
|
||||
results.filter(({ rideCount }) => rideCount > 0).map(({ id, rideCount }) => [id, rideCount])
|
||||
results.filter(({ rideCount }) => rideCount != null && rideCount > 0).map(({ id, rideCount }) => [id, rideCount!])
|
||||
);
|
||||
coasterCounts = Object.fromEntries(
|
||||
results.filter(({ coasterCount }) => coasterCount > 0).map(({ id, coasterCount }) => [id, coasterCount])
|
||||
@@ -103,7 +140,9 @@ export default async function HomePage({ searchParams }: PageProps) {
|
||||
data={data}
|
||||
rideCounts={rideCounts}
|
||||
coasterCounts={coasterCounts}
|
||||
openParkIds={openParkIds}
|
||||
closingParkIds={closingParkIds}
|
||||
weatherDelayParkIds={weatherDelayParkIds}
|
||||
hasCoasterData={hasCoasterData}
|
||||
scrapedCount={scrapedCount}
|
||||
/>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { PARK_MAP } from "@/lib/parks";
|
||||
import { openDb, getParkMonthData, getApiId } from "@/lib/db";
|
||||
import { scrapeRidesForDay } from "@/lib/scrapers/sixflags";
|
||||
import { fetchLiveRides } from "@/lib/scrapers/queuetimes";
|
||||
import { fetchToday } from "@/lib/scrapers/sixflags";
|
||||
import { QUEUE_TIMES_IDS } from "@/lib/queue-times-map";
|
||||
import { readParkMeta, getCoasterSet } from "@/lib/park-meta";
|
||||
import { ParkMonthCalendar } from "@/components/ParkMonthCalendar";
|
||||
@@ -44,7 +45,13 @@ export default async function ParkPage({ params, searchParams }: PageProps) {
|
||||
const apiId = getApiId(db, id);
|
||||
db.close();
|
||||
|
||||
const todayData = monthData[today];
|
||||
// Prefer live today data from the Six Flags API (5-min ISR cache) so that
|
||||
// weather delays and hour changes surface immediately rather than showing
|
||||
// stale DB values. Fall back to DB if the API call fails.
|
||||
const liveToday = apiId !== null ? await fetchToday(apiId, 300).catch(() => null) : null;
|
||||
const todayData = liveToday
|
||||
? { isOpen: liveToday.isOpen, hoursLabel: liveToday.hoursLabel ?? null, specialType: liveToday.specialType ?? null }
|
||||
: monthData[today];
|
||||
const parkOpenToday = todayData?.isOpen && todayData?.hoursLabel;
|
||||
|
||||
// ── Ride data: try live Queue-Times first, fall back to schedule ──────────
|
||||
@@ -73,10 +80,15 @@ export default async function ParkPage({ params, searchParams }: PageProps) {
|
||||
}
|
||||
}
|
||||
|
||||
// Only hit the schedule API as a fallback when live data is unavailable
|
||||
// Weather delay: park is within operating hours but queue-times shows 0 open rides
|
||||
const isWeatherDelay =
|
||||
withinWindow &&
|
||||
liveRides !== null &&
|
||||
liveRides.rides.length > 0 &&
|
||||
liveRides.rides.every((r) => !r.isOpen);
|
||||
|
||||
// Only hit the schedule API as a fallback when Queue-Times live data is unavailable.
|
||||
if (!liveRides && apiId !== null) {
|
||||
// Note: the API drops today's date from its response (only returns future dates),
|
||||
// so scrapeRidesForDay may fall back to the nearest upcoming date.
|
||||
ridesResult = await scrapeRidesForDay(apiId, today);
|
||||
}
|
||||
|
||||
@@ -157,6 +169,7 @@ export default async function ParkPage({ params, searchParams }: PageProps) {
|
||||
<LiveRidePanel
|
||||
liveRides={liveRides}
|
||||
parkOpenToday={!!parkOpenToday}
|
||||
isWeatherDelay={isWeatherDelay}
|
||||
/>
|
||||
) : (
|
||||
<RideList
|
||||
|
||||
@@ -11,6 +11,34 @@ import { PARKS, groupByRegion } from "@/lib/parks";
|
||||
import type { DayData } from "@/lib/db";
|
||||
|
||||
const REFRESH_INTERVAL_MS = 2 * 60 * 1000; // 2 minutes
|
||||
const OPEN_REFRESH_BUFFER_MS = 30_000; // 30s after opening time before hitting the API
|
||||
|
||||
/** Parse the opening hour/minute from a hoursLabel like "10am", "10:30am", "11am". */
|
||||
function parseOpenTime(hoursLabel: string): { hour: number; minute: number } | null {
|
||||
const openPart = hoursLabel.split(" - ")[0].trim();
|
||||
const match = openPart.match(/^(\d+)(?::(\d+))?(am|pm)$/i);
|
||||
if (!match) return null;
|
||||
let hour = parseInt(match[1], 10);
|
||||
const minute = match[2] ? parseInt(match[2], 10) : 0;
|
||||
const period = match[3].toLowerCase();
|
||||
if (period === "pm" && hour !== 12) hour += 12;
|
||||
if (period === "am" && hour === 12) hour = 0;
|
||||
return { hour, minute };
|
||||
}
|
||||
|
||||
/** Milliseconds from now until a given local clock time in a timezone. Negative if already past. */
|
||||
function msUntilLocalTime(hour: number, minute: number, timezone: string): number {
|
||||
const now = new Date();
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: timezone,
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
}).formatToParts(now);
|
||||
const localHour = parseInt(parts.find(p => p.type === "hour")!.value, 10) % 24;
|
||||
const localMinute = parseInt(parts.find(p => p.type === "minute")!.value, 10);
|
||||
return ((hour * 60 + minute) - (localHour * 60 + localMinute)) * 60_000;
|
||||
}
|
||||
|
||||
const COASTER_MODE_KEY = "coasterMode";
|
||||
|
||||
@@ -22,7 +50,9 @@ interface HomePageClientProps {
|
||||
data: Record<string, Record<string, DayData>>;
|
||||
rideCounts: Record<string, number>;
|
||||
coasterCounts: Record<string, number>;
|
||||
openParkIds: string[];
|
||||
closingParkIds: string[];
|
||||
weatherDelayParkIds: string[];
|
||||
hasCoasterData: boolean;
|
||||
scrapedCount: number;
|
||||
}
|
||||
@@ -35,7 +65,9 @@ export function HomePageClient({
|
||||
data,
|
||||
rideCounts,
|
||||
coasterCounts,
|
||||
openParkIds,
|
||||
closingParkIds,
|
||||
weatherDelayParkIds,
|
||||
hasCoasterData,
|
||||
scrapedCount,
|
||||
}: HomePageClientProps) {
|
||||
@@ -54,6 +86,29 @@ export function HomePageClient({
|
||||
return () => clearInterval(id);
|
||||
}, [isCurrentWeek, router]);
|
||||
|
||||
// Schedule a targeted refresh at each park's exact opening time so the
|
||||
// open indicator and ride counts appear immediately rather than waiting
|
||||
// up to 2 minutes for the next polling cycle.
|
||||
useEffect(() => {
|
||||
if (!isCurrentWeek) return;
|
||||
const timeouts: ReturnType<typeof setTimeout>[] = [];
|
||||
|
||||
for (const park of PARKS) {
|
||||
const dayData = data[park.id]?.[today];
|
||||
if (!dayData?.isOpen || !dayData.hoursLabel) continue;
|
||||
const openTime = parseOpenTime(dayData.hoursLabel);
|
||||
if (!openTime) continue;
|
||||
const ms = msUntilLocalTime(openTime.hour, openTime.minute, park.timezone);
|
||||
// Only schedule if opening is still in the future (within the next 24h)
|
||||
if (ms > 0 && ms < 24 * 60 * 60 * 1000) {
|
||||
timeouts.push(setTimeout(() => router.refresh(), ms)); // mark as open
|
||||
timeouts.push(setTimeout(() => router.refresh(), ms + OPEN_REFRESH_BUFFER_MS)); // pick up ride counts
|
||||
}
|
||||
}
|
||||
|
||||
return () => timeouts.forEach(clearTimeout);
|
||||
}, [isCurrentWeek, today, data, router]);
|
||||
|
||||
// Remember the current week so the park page back button returns here.
|
||||
useEffect(() => {
|
||||
localStorage.setItem("lastWeek", weekStart);
|
||||
@@ -180,7 +235,9 @@ export function HomePageClient({
|
||||
today={today}
|
||||
rideCounts={activeCounts}
|
||||
coastersOnly={coastersOnly}
|
||||
openParkIds={openParkIds}
|
||||
closingParkIds={closingParkIds}
|
||||
weatherDelayParkIds={weatherDelayParkIds}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -193,7 +250,9 @@ export function HomePageClient({
|
||||
grouped={grouped}
|
||||
rideCounts={activeCounts}
|
||||
coastersOnly={coastersOnly}
|
||||
openParkIds={openParkIds}
|
||||
closingParkIds={closingParkIds}
|
||||
weatherDelayParkIds={weatherDelayParkIds}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -6,9 +6,10 @@ import type { LiveRidesResult, LiveRide } from "@/lib/scrapers/queuetimes";
|
||||
interface LiveRidePanelProps {
|
||||
liveRides: LiveRidesResult;
|
||||
parkOpenToday: boolean;
|
||||
isWeatherDelay?: boolean;
|
||||
}
|
||||
|
||||
export function LiveRidePanel({ liveRides, parkOpenToday }: LiveRidePanelProps) {
|
||||
export function LiveRidePanel({ liveRides, parkOpenToday, isWeatherDelay }: LiveRidePanelProps) {
|
||||
const { rides } = liveRides;
|
||||
const hasCoasters = rides.some((r) => r.isCoaster);
|
||||
const [coastersOnly, setCoastersOnly] = useState(false);
|
||||
@@ -49,6 +50,19 @@ export function LiveRidePanel({ liveRides, parkOpenToday }: LiveRidePanelProps)
|
||||
}}>
|
||||
{openRides.length} open
|
||||
</div>
|
||||
) : isWeatherDelay ? (
|
||||
<div style={{
|
||||
background: "var(--color-weather-bg)",
|
||||
border: "1px solid var(--color-weather-border)",
|
||||
borderRadius: 20,
|
||||
padding: "4px 12px",
|
||||
fontSize: "0.72rem",
|
||||
fontWeight: 600,
|
||||
color: "var(--color-weather-text)",
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
⛈ Weather Delay — all rides currently closed
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
background: "var(--color-surface)",
|
||||
|
||||
@@ -10,10 +10,12 @@ interface MobileCardListProps {
|
||||
today: string;
|
||||
rideCounts?: Record<string, number>;
|
||||
coastersOnly?: boolean;
|
||||
openParkIds?: string[];
|
||||
closingParkIds?: string[];
|
||||
weatherDelayParkIds?: string[];
|
||||
}
|
||||
|
||||
export function MobileCardList({ grouped, weekDates, data, today, rideCounts, coastersOnly, closingParkIds }: MobileCardListProps) {
|
||||
export function MobileCardList({ grouped, weekDates, data, today, rideCounts, coastersOnly, openParkIds, closingParkIds, weatherDelayParkIds }: MobileCardListProps) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 20, paddingTop: 14 }}>
|
||||
{Array.from(grouped.entries()).map(([region, parks]) => (
|
||||
@@ -22,25 +24,17 @@ export function MobileCardList({ grouped, weekDates, data, today, rideCounts, co
|
||||
<div style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
marginBottom: 10,
|
||||
paddingLeft: 2,
|
||||
}}>
|
||||
<div style={{
|
||||
width: 3,
|
||||
height: 14,
|
||||
borderRadius: 2,
|
||||
background: "var(--color-region-accent)",
|
||||
flexShrink: 0,
|
||||
}} />
|
||||
<span style={{
|
||||
fontSize: "0.65rem",
|
||||
fontSize: "0.6rem",
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.1em",
|
||||
letterSpacing: "0.14em",
|
||||
textTransform: "uppercase",
|
||||
color: "var(--color-text-muted)",
|
||||
color: "var(--color-text-secondary)",
|
||||
}}>
|
||||
{region}
|
||||
— {region} —
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -55,7 +49,9 @@ export function MobileCardList({ grouped, weekDates, data, today, rideCounts, co
|
||||
today={today}
|
||||
openRideCount={rideCounts?.[park.id]}
|
||||
coastersOnly={coastersOnly}
|
||||
isOpen={openParkIds?.includes(park.id)}
|
||||
isClosing={closingParkIds?.includes(park.id)}
|
||||
isWeatherDelay={weatherDelayParkIds?.includes(park.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -10,12 +10,14 @@ interface ParkCardProps {
|
||||
today: string;
|
||||
openRideCount?: number;
|
||||
coastersOnly?: boolean;
|
||||
isOpen?: boolean;
|
||||
isClosing?: boolean;
|
||||
isWeatherDelay?: boolean;
|
||||
}
|
||||
|
||||
const DOW = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||
|
||||
export function ParkCard({ park, weekDates, parkData, today, openRideCount, coastersOnly, isClosing }: ParkCardProps) {
|
||||
export function ParkCard({ park, weekDates, parkData, today, openRideCount, coastersOnly, isOpen, isClosing, isWeatherDelay }: ParkCardProps) {
|
||||
const openDays = weekDates.filter((d) => parkData[d]?.isOpen && parkData[d]?.hoursLabel);
|
||||
const tzAbbr = getTimezoneAbbr(park.timezone);
|
||||
const isOpenToday = openDays.includes(today);
|
||||
@@ -29,6 +31,9 @@ export function ParkCard({ park, weekDates, parkData, today, openRideCount, coas
|
||||
<div className="park-card" style={{
|
||||
background: "var(--color-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
borderLeft: isOpen
|
||||
? `3px solid ${isWeatherDelay ? "var(--color-weather-border)" : isClosing ? "var(--color-closing-border)" : "var(--color-open-border)"}`
|
||||
: "1px solid var(--color-border)",
|
||||
borderRadius: 12,
|
||||
overflow: "hidden",
|
||||
}}>
|
||||
@@ -38,6 +43,7 @@ export function ParkCard({ park, weekDates, parkData, today, openRideCount, coas
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "space-between",
|
||||
flexWrap: "wrap",
|
||||
gap: 12,
|
||||
}}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
@@ -58,20 +64,20 @@ export function ParkCard({ park, weekDates, parkData, today, openRideCount, coas
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 5, flexShrink: 0 }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 5 }}>
|
||||
{isOpenToday ? (
|
||||
<div style={{
|
||||
background: isClosing ? "var(--color-closing-bg)" : "var(--color-open-bg)",
|
||||
border: `1px solid ${isClosing ? "var(--color-closing-border)" : "var(--color-open-border)"}`,
|
||||
background: isWeatherDelay ? "var(--color-weather-bg)" : isClosing ? "var(--color-closing-bg)" : "var(--color-open-bg)",
|
||||
border: `1px solid ${isWeatherDelay ? "var(--color-weather-border)" : isClosing ? "var(--color-closing-border)" : "var(--color-open-border)"}`,
|
||||
borderRadius: 20,
|
||||
padding: "4px 10px",
|
||||
fontSize: "0.65rem",
|
||||
fontWeight: 700,
|
||||
color: isClosing ? "var(--color-closing-text)" : "var(--color-open-text)",
|
||||
color: isWeatherDelay ? "var(--color-weather-text)" : isClosing ? "var(--color-closing-text)" : "var(--color-open-text)",
|
||||
whiteSpace: "nowrap",
|
||||
letterSpacing: "0.03em",
|
||||
}}>
|
||||
{isClosing ? "Closing" : "Open today"}
|
||||
{isWeatherDelay ? "⛈ Weather Delay" : isClosing ? "Closing" : "Open today"}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
@@ -87,7 +93,17 @@ export function ParkCard({ park, weekDates, parkData, today, openRideCount, coas
|
||||
Closed today
|
||||
</div>
|
||||
)}
|
||||
{isOpenToday && openRideCount !== undefined && (
|
||||
{isOpenToday && isWeatherDelay && (
|
||||
<div style={{
|
||||
fontSize: "0.65rem",
|
||||
color: "var(--color-weather-hours, #bfdbfe)",
|
||||
fontWeight: 500,
|
||||
textAlign: "right",
|
||||
}}>
|
||||
⛈ Weather Delay
|
||||
</div>
|
||||
)}
|
||||
{isOpenToday && !isWeatherDelay && openRideCount !== undefined && (
|
||||
<div style={{
|
||||
fontSize: "0.65rem",
|
||||
color: isClosing ? "var(--color-closing-hours)" : "var(--color-open-hours)",
|
||||
|
||||
@@ -167,7 +167,7 @@ export function ParkMonthCalendar({ parkId, year, month, monthData, today, timez
|
||||
borderLeft: isToday ? "2px solid var(--color-today-border)" : "none",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 8,
|
||||
gap: 6,
|
||||
}}>
|
||||
{/* Date number */}
|
||||
<span style={{
|
||||
@@ -203,16 +203,16 @@ export function ParkMonthCalendar({ parkId, year, month, monthData, today, timez
|
||||
background: "var(--color-ph-bg)",
|
||||
border: "1px solid var(--color-ph-border)",
|
||||
borderRadius: 5,
|
||||
padding: "3px 6px",
|
||||
padding: "8px 6px",
|
||||
textAlign: "center",
|
||||
}}>
|
||||
<div style={{ fontSize: "0.6rem", fontWeight: 700, color: "var(--color-ph-label)", textTransform: "uppercase", letterSpacing: "0.05em" }}>
|
||||
Passholder
|
||||
</div>
|
||||
<div style={{ fontSize: "0.65rem", color: "var(--color-ph-hours)", marginTop: 2 }}>
|
||||
<div style={{ fontSize: "0.65rem", color: "var(--color-ph-hours)", marginTop: 4 }}>
|
||||
{dayData.hoursLabel}
|
||||
</div>
|
||||
<div style={{ fontSize: "0.58rem", color: "var(--color-ph-label)", opacity: 0.75, marginTop: 1, letterSpacing: "0.04em" }}>
|
||||
<div style={{ fontSize: "0.58rem", color: "var(--color-ph-label)", opacity: 0.75, marginTop: 3, letterSpacing: "0.04em" }}>
|
||||
{tzAbbr}
|
||||
</div>
|
||||
</div>
|
||||
@@ -221,13 +221,13 @@ export function ParkMonthCalendar({ parkId, year, month, monthData, today, timez
|
||||
background: "var(--color-open-bg)",
|
||||
border: "1px solid var(--color-open-border)",
|
||||
borderRadius: 5,
|
||||
padding: "3px 6px",
|
||||
padding: "8px 6px",
|
||||
textAlign: "center",
|
||||
}}>
|
||||
<div style={{ fontSize: "0.65rem", color: "var(--color-open-hours)" }}>
|
||||
{dayData.hoursLabel}
|
||||
</div>
|
||||
<div style={{ fontSize: "0.58rem", color: "var(--color-open-hours)", opacity: 0.6, marginTop: 1, letterSpacing: "0.04em" }}>
|
||||
<div style={{ fontSize: "0.58rem", color: "var(--color-open-hours)", opacity: 0.6, marginTop: 4, letterSpacing: "0.04em" }}>
|
||||
{tzAbbr}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,9 @@ interface WeekCalendarProps {
|
||||
grouped?: Map<Region, Park[]>; // pre-grouped parks (if provided, renders region headers)
|
||||
rideCounts?: Record<string, number>; // parkId → open ride/coaster count for today
|
||||
coastersOnly?: boolean;
|
||||
closingParkIds?: string[]; // parks in the post-close wind-down buffer
|
||||
openParkIds?: string[];
|
||||
closingParkIds?: string[];
|
||||
weatherDelayParkIds?: string[];
|
||||
}
|
||||
|
||||
const DOW = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
@@ -46,7 +48,7 @@ function DayCell({
|
||||
verticalAlign: "middle",
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
borderLeft: "1px solid var(--color-border)",
|
||||
height: 56,
|
||||
height: 72,
|
||||
background: isWeekend ? "var(--color-weekend-header)" : "transparent",
|
||||
transition: "background 120ms ease",
|
||||
};
|
||||
@@ -69,7 +71,7 @@ function DayCell({
|
||||
|
||||
if (dayData.specialType === "passholder_preview") {
|
||||
return (
|
||||
<td style={{ ...base, padding: 4 }}>
|
||||
<td style={{ ...base, padding: 6 }}>
|
||||
<div style={{
|
||||
background: "var(--color-ph-bg)",
|
||||
border: "1px solid var(--color-ph-border)",
|
||||
@@ -164,17 +166,16 @@ function RegionHeader({ region, colSpan }: { region: string; colSpan: number })
|
||||
padding: "10px 14px 6px",
|
||||
background: "var(--color-region-bg)",
|
||||
borderBottom: "1px solid var(--color-border-subtle)",
|
||||
borderLeft: "3px solid var(--color-region-accent)",
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
fontSize: "0.65rem",
|
||||
fontSize: "0.6rem",
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.1em",
|
||||
letterSpacing: "0.14em",
|
||||
textTransform: "uppercase",
|
||||
color: "var(--color-text-muted)",
|
||||
color: "var(--color-text-secondary)",
|
||||
}}>
|
||||
{region}
|
||||
— {region} —
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -189,7 +190,9 @@ function ParkRow({
|
||||
parkData,
|
||||
rideCounts,
|
||||
coastersOnly,
|
||||
openParkIds,
|
||||
closingParkIds,
|
||||
weatherDelayParkIds,
|
||||
}: {
|
||||
park: Park;
|
||||
parkIdx: number;
|
||||
@@ -198,11 +201,15 @@ function ParkRow({
|
||||
parkData: Record<string, DayData>;
|
||||
rideCounts?: Record<string, number>;
|
||||
coastersOnly?: boolean;
|
||||
openParkIds?: string[];
|
||||
closingParkIds?: string[];
|
||||
weatherDelayParkIds?: string[];
|
||||
}) {
|
||||
const rowBg = parkIdx % 2 === 0 ? "var(--color-bg)" : "var(--color-surface)";
|
||||
const tzAbbr = getTimezoneAbbr(park.timezone);
|
||||
const isOpen = openParkIds?.includes(park.id) ?? false;
|
||||
const isClosing = closingParkIds?.includes(park.id) ?? false;
|
||||
const isWeatherDelay = weatherDelayParkIds?.includes(park.id) ?? false;
|
||||
return (
|
||||
<tr
|
||||
className="park-row"
|
||||
@@ -216,7 +223,9 @@ function ParkRow({
|
||||
padding: 0,
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
borderRight: "1px solid var(--color-border)",
|
||||
whiteSpace: "nowrap",
|
||||
borderLeft: isOpen
|
||||
? `3px solid ${isWeatherDelay ? "var(--color-weather-border)" : isClosing ? "var(--color-closing-border)" : "var(--color-open-border)"}`
|
||||
: "3px solid transparent",
|
||||
verticalAlign: "middle",
|
||||
background: rowBg,
|
||||
transition: "background 120ms ease",
|
||||
@@ -228,30 +237,23 @@ function ParkRow({
|
||||
padding: "10px 14px",
|
||||
gap: 10,
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||
<span style={{ fontWeight: 500, fontSize: "0.85rem", lineHeight: 1.2, color: "var(--color-text)" }}>
|
||||
<span style={{ fontWeight: 500, fontSize: "0.85rem", lineHeight: 1.2, color: "var(--color-text)", whiteSpace: "nowrap" }}>
|
||||
{park.name}
|
||||
</span>
|
||||
{rideCounts?.[park.id] !== undefined && (
|
||||
<span style={{
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: "50%",
|
||||
background: isClosing ? "var(--color-closing-text)" : "var(--color-open-text)",
|
||||
flexShrink: 0,
|
||||
boxShadow: isClosing
|
||||
? "0 0 5px var(--color-closing-text)"
|
||||
: "0 0 5px var(--color-open-text)",
|
||||
}} />
|
||||
)}
|
||||
</div>
|
||||
<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.72rem", color: isClosing ? "var(--color-closing-hours)" : "var(--color-open-hours)", fontWeight: 600, whiteSpace: "nowrap", flexShrink: 0 }}>
|
||||
{isWeatherDelay && (
|
||||
<div style={{ fontSize: "0.72rem", color: "var(--color-weather-text)", fontWeight: 600, textAlign: "center", maxWidth: 72, lineHeight: 1.3 }}>
|
||||
Weather Delay
|
||||
</div>
|
||||
)}
|
||||
{!isWeatherDelay && rideCounts?.[park.id] !== undefined && (
|
||||
<div style={{ fontSize: "0.72rem", color: isClosing ? "var(--color-closing-hours)" : "var(--color-open-hours)", fontWeight: 600, textAlign: "center", maxWidth: 72, lineHeight: 1.3 }}>
|
||||
{rideCounts[park.id]} {coastersOnly
|
||||
? (rideCounts[park.id] === 1 ? "coaster" : "coasters")
|
||||
: (rideCounts[park.id] === 1 ? "ride" : "rides")} operating
|
||||
@@ -272,7 +274,7 @@ function ParkRow({
|
||||
);
|
||||
}
|
||||
|
||||
export function WeekCalendar({ parks, weekDates, data, grouped, rideCounts, coastersOnly, closingParkIds }: WeekCalendarProps) {
|
||||
export function WeekCalendar({ parks, weekDates, data, grouped, rideCounts, coastersOnly, openParkIds, closingParkIds, weatherDelayParkIds }: WeekCalendarProps) {
|
||||
const today = getTodayLocal();
|
||||
const parsedDates = weekDates.map(parseDate);
|
||||
|
||||
@@ -287,7 +289,7 @@ export function WeekCalendar({ parks, weekDates, data, grouped, rideCounts, coas
|
||||
const colSpan = weekDates.length + 1; // park col + 7 day cols
|
||||
|
||||
return (
|
||||
<div style={{ overflowX: "auto", overflowY: "visible" }}>
|
||||
<div style={{ overflowX: "auto", overflowY: "visible", paddingRight: 16 }}>
|
||||
<table style={{
|
||||
borderCollapse: "collapse",
|
||||
width: "100%",
|
||||
@@ -388,7 +390,9 @@ export function WeekCalendar({ parks, weekDates, data, grouped, rideCounts, coas
|
||||
parkData={data[park.id] ?? {}}
|
||||
rideCounts={rideCounts}
|
||||
coastersOnly={coastersOnly}
|
||||
openParkIds={openParkIds}
|
||||
closingParkIds={closingParkIds}
|
||||
weatherDelayParkIds={weatherDelayParkIds}
|
||||
/>
|
||||
))}
|
||||
</Fragment>
|
||||
|
||||
10
lib/db.ts
10
lib/db.ts
@@ -46,12 +46,10 @@ export function upsertDay(
|
||||
hoursLabel?: string,
|
||||
specialType?: string
|
||||
) {
|
||||
// Today and past dates: INSERT new rows freely, but NEVER overwrite existing records.
|
||||
// Once an operating day begins the API drops that date from its response, so a
|
||||
// re-scrape would incorrectly record the day as closed. The DB row written when
|
||||
// the date was still in the future is the permanent truth for that day.
|
||||
// Today and future dates: full upsert — hours can change (e.g. weather delays,
|
||||
// early closures) and the dateless API endpoint now returns today's live data.
|
||||
//
|
||||
// Future dates only: full upsert — hours can change and closures can be added.
|
||||
// Past dates: INSERT-only — never overwrite once the day has passed.
|
||||
db.prepare(`
|
||||
INSERT INTO park_days (park_id, date, is_open, hours_label, special_type, scraped_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
@@ -60,7 +58,7 @@ export function upsertDay(
|
||||
hours_label = excluded.hours_label,
|
||||
special_type = excluded.special_type,
|
||||
scraped_at = excluded.scraped_at
|
||||
WHERE park_days.date > date('now')
|
||||
WHERE park_days.date >= date('now')
|
||||
`).run(parkId, date, isOpen ? 1 : 0, hoursLabel ?? null, specialType ?? null, new Date().toISOString());
|
||||
}
|
||||
|
||||
|
||||
@@ -166,13 +166,48 @@ function apiDateToIso(apiDate: string): string {
|
||||
return `${yyyy}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
/** Parse a single ApiDay into a DayResult. Shared by scrapeMonth and fetchToday. */
|
||||
function parseApiDay(d: ApiDay): DayResult {
|
||||
const date = parseApiDate(d.date);
|
||||
const operating =
|
||||
d.operatings?.find((o) => o.operatingTypeName === "Park") ??
|
||||
d.operatings?.[0];
|
||||
const item = operating?.items?.[0];
|
||||
const hoursLabel =
|
||||
item?.timeFrom && item?.timeTo
|
||||
? `${fmt24(item.timeFrom)} – ${fmt24(item.timeTo)}`
|
||||
: undefined;
|
||||
const isPassholderPreview = d.events?.some((e) =>
|
||||
e.extEventName.toLowerCase().includes("passholder preview")
|
||||
) ?? false;
|
||||
const isBuyout = item?.isBuyout ?? false;
|
||||
const isOpen = !d.isParkClosed && hoursLabel !== undefined && (!isBuyout || isPassholderPreview);
|
||||
const specialType: DayResult["specialType"] = isPassholderPreview ? "passholder_preview" : undefined;
|
||||
return { date, isOpen, hoursLabel: isOpen ? hoursLabel : undefined, specialType };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch ride operating status for a given date.
|
||||
* Fetch today's operating data directly (no date param = API returns today).
|
||||
* Pass `revalidate` (seconds) for Next.js ISR caching; omit for a fully fresh fetch.
|
||||
*/
|
||||
export async function fetchToday(apiId: number, revalidate?: number): Promise<DayResult | null> {
|
||||
try {
|
||||
const url = `${API_BASE}/${apiId}`;
|
||||
const raw = await fetchApi(url, 0, 0, revalidate);
|
||||
if (!raw.dates.length) return null;
|
||||
return parseApiDay(raw.dates[0]);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch ride operating status for a given date. Used as a fallback when
|
||||
* Queue-Times live data is unavailable.
|
||||
*
|
||||
* The Six Flags API drops dates that have already started (including today),
|
||||
* returning only tomorrow onwards. When the requested date is missing, we fall
|
||||
* back to the nearest available upcoming date in the same month's response so
|
||||
* the UI can still show a useful (if approximate) schedule.
|
||||
* The monthly API endpoint (`?date=YYYYMM`) may not include today; use
|
||||
* `fetchToday(apiId)` to get today's park hours directly. The fallback
|
||||
* chain here will find the nearest upcoming date if an exact match is missing.
|
||||
*
|
||||
* Returns null if no ride data could be found at all (API error, pre-season,
|
||||
* no venues in response).
|
||||
@@ -286,30 +321,7 @@ export async function scrapeMonth(
|
||||
|
||||
const data = await fetchApi(url);
|
||||
|
||||
return data.dates.map((d): DayResult => {
|
||||
const date = parseApiDate(d.date);
|
||||
// Prefer the "Park" operating entry; fall back to first entry
|
||||
const operating =
|
||||
d.operatings?.find((o) => o.operatingTypeName === "Park") ??
|
||||
d.operatings?.[0];
|
||||
const item = operating?.items?.[0];
|
||||
const hoursLabel =
|
||||
item?.timeFrom && item?.timeTo
|
||||
? `${fmt24(item.timeFrom)} – ${fmt24(item.timeTo)}`
|
||||
: undefined;
|
||||
|
||||
const isPassholderPreview = d.events?.some((e) =>
|
||||
e.extEventName.toLowerCase().includes("passholder preview")
|
||||
) ?? false;
|
||||
|
||||
const isBuyout = item?.isBuyout ?? false;
|
||||
|
||||
// Buyout days are private events — treat as closed unless it's a passholder preview
|
||||
const isOpen = !d.isParkClosed && hoursLabel !== undefined && (!isBuyout || isPassholderPreview);
|
||||
const specialType: DayResult["specialType"] = isPassholderPreview ? "passholder_preview" : undefined;
|
||||
|
||||
return { date, isOpen, hoursLabel: isOpen ? hoursLabel : undefined, specialType };
|
||||
});
|
||||
return data.dates.map(parseApiDay);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
import { openDb, upsertDay, getApiId, isMonthScraped } from "../lib/db";
|
||||
import { PARKS } from "../lib/parks";
|
||||
import { scrapeMonth, RateLimitError } from "../lib/scrapers/sixflags";
|
||||
import { scrapeMonth, fetchToday, RateLimitError } from "../lib/scrapers/sixflags";
|
||||
import { readParkMeta, writeParkMeta, areCoastersStale } from "../lib/park-meta";
|
||||
import { scrapeRcdbCoasters } from "../lib/scrapers/rcdb";
|
||||
|
||||
@@ -100,6 +100,25 @@ async function main() {
|
||||
console.log(`\n ${totalFetched} fetched ${totalSkipped} skipped ${totalErrors} errors`);
|
||||
if (totalErrors > 0) console.log(" Re-run to retry failed months.");
|
||||
|
||||
// ── Today scrape (always fresh — dateless endpoint returns current day) ────
|
||||
console.log("\n── Today's data ──");
|
||||
for (const park of ready) {
|
||||
const apiId = getApiId(db, park.id)!;
|
||||
process.stdout.write(` ${park.shortName.padEnd(22)} `);
|
||||
try {
|
||||
const today = await fetchToday(apiId);
|
||||
if (today) {
|
||||
upsertDay(db, park.id, today.date, today.isOpen, today.hoursLabel, today.specialType);
|
||||
console.log(today.isOpen ? `open ${today.hoursLabel ?? ""}` : "closed");
|
||||
} else {
|
||||
console.log("no data");
|
||||
}
|
||||
} catch {
|
||||
console.log("error");
|
||||
}
|
||||
await sleep(500);
|
||||
}
|
||||
|
||||
db.close();
|
||||
|
||||
// ── RCDB coaster scrape (30-day staleness) ────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user