Compare commits

...

28 Commits

Author SHA1 Message Date
0009af751f Update README.md
All checks were successful
Build and Deploy / Build & Push (push) Successful in 19s
2026-04-05 17:39:24 -04:00
4063ded9ec Update README.md
All checks were successful
Build and Deploy / Build & Push (push) Successful in 1m9s
2026-04-05 17:34:18 -04:00
Josh Wright
f0faff412c feat: use dateless Six Flags API endpoint for live today data
All checks were successful
Build and Deploy / Build & Push (push) Successful in 17s
The API without a date param returns today's operating data directly,
invalidating the previous assumption that today's date was always missing.

- Add fetchToday(apiId, revalidate?) to sixflags.ts — calls the dateless
  endpoint with optional ISR cache
- Extract parseApiDay() helper shared by scrapeMonth and fetchToday
- Update upsertDay WHERE clause: >= date('now') so today can be updated
  (was > date('now'), which froze today after first write)
- scrape.ts: add a today-scrape pass after the monthly loop so each run
  always writes fresh today data to the DB
- app/page.tsx: fetch live today data for all parks (5-min ISR) and merge
  into the data map before computing open/closing/weatherDelay status
- app/park/[id]/page.tsx: prefer live today data from API for todayData
  so weather delays and hour changes surface within 5 minutes
- scrapeRidesForDay: update comment only — role unchanged (QT fallback)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 16:54:06 -04:00
Josh Wright
08db97faa8 polish: center-align ride count and weather delay text in park column
All checks were successful
Build and Deploy / Build & Push (push) Successful in 53s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 16:07:24 -04:00
Josh Wright
054c82529b polish: center-align weather delay text so it stacks neatly within its box
All checks were successful
Build and Deploy / Build & Push (push) Successful in 1m8s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 16:02:23 -04:00
Josh Wright
8437cadee0 polish: weather delay text matches ride count style — blue, no emoji
All checks were successful
Build and Deploy / Build & Push (push) Successful in 1m14s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 15:59:01 -04:00
Josh Wright
b4af83b879 fix: weather delay text wraps within its box, no longer collides with park name
All checks were successful
Build and Deploy / Build & Push (push) Successful in 54s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 15:56:12 -04:00
Josh Wright
b1204c95cb fix: ride count stays right-aligned, wraps within its own box, never drops below park name
All checks were successful
Build and Deploy / Build & Push (push) Successful in 52s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 15:52:48 -04:00
Josh Wright
a5b98f93e6 fix: constrain ride count width so text wraps within its own box
All checks were successful
Build and Deploy / Build & Push (push) Successful in 56s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 15:48:53 -04:00
Josh Wright
b2ef342bf4 fix: ride count now wraps below park name instead of colliding
Removed flex:1 from left side so ride count has a real minimum width to
trigger wrapping. Added whiteSpace:nowrap so flexbox knows when to wrap it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 15:45:49 -04:00
Josh Wright
e405170c8b fix: allow ride count to wrap below park name on narrow mobile cards
All checks were successful
Build and Deploy / Build & Push (push) Successful in 1m1s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 15:34:36 -04:00
Josh Wright
fd99f6f390 fix: allow ride count to wrap below park name on narrow columns
All checks were successful
Build and Deploy / Build & Push (push) Successful in 58s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 15:30:56 -04:00
Josh Wright
4e6040a781 fix: add right padding to table scroll container to clear scrollbar
All checks were successful
Build and Deploy / Build & Push (push) Successful in 57s
Padding on the parent main element doesn't work with overflow:auto — the
fix belongs on the scrollable div wrapping the table itself.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 15:27:18 -04:00
Josh Wright
7904475ddc polish: add right padding to main content to clear scrollbar
All checks were successful
Build and Deploy / Build & Push (push) Successful in 55s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 15:23:45 -04:00
Josh Wright
a84bbcac31 polish: taller week calendar cells with more padding around pills
All checks were successful
Build and Deploy / Build & Push (push) Successful in 51s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 15:20:41 -04:00
Josh Wright
569d0a41e2 polish: more padding and line spacing in month calendar pills, taller min row
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 15:19:46 -04:00
Josh Wright
c6c32a168b polish: more breathing room inside month calendar day pills
All checks were successful
Build and Deploy / Build & Push (push) Successful in 1m4s
Increased pill vertical padding (3px→5px) and internal line gaps (1-2px→2-3px)
so the stacked hours/timezone text feels less cramped.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 15:14:01 -04:00
Josh Wright
cba8218fe8 feat: replace dot with left border line on park rows/cards
All checks were successful
Build and Deploy / Build & Push (push) Successful in 51s
Open parks get a colored left border (green/amber/blue) instead of a dot.
Region headers lose their accent line; distinguished by "— REGION —" format
with higher-contrast text instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 15:07:42 -04:00
Josh Wright
695feff443 fix: restore Weather Delay text in mobile card ride count area
All checks were successful
Build and Deploy / Build & Push (push) Successful in 56s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 15:02:11 -04:00
Josh Wright
f85cc084b7 feat: blue dot + Weather Delay notice for storm-closed parks
- Dot turns blue (instead of green) during weather delay on homepage
- Mobile card "Open today" badge becomes blue "⛈ Weather Delay"
- Park page LiveRidePanel shows a blue "⛈ Weather Delay — all rides currently closed" badge instead of "Not open yet — check back soon"
- Added --color-weather-* CSS variables (blue palette)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 15:01:37 -04:00
Josh Wright
32f0d05038 feat: show open dot based on hours, Weather Delay when queue-times shows 0 rides
All checks were successful
Build and Deploy / Build & Push (push) Successful in 49s
Park open indicator now derives from scheduled hours, not ride counts.
Parks with queue-times coverage but 0 open rides (e.g. storm) show a
"⛈ Weather Delay" notice instead of a ride count on both desktop and mobile.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 14:56:54 -04:00
Josh Wright
d84a15ad64 fix: restore 240px park column width — clamp() unreliable in col elements
All checks were successful
Build and Deploy / Build & Push (push) Successful in 54s
The actual overflow fix was removing whiteSpace:nowrap from the td.
With that gone, 240px is sufficient and content wraps naturally when tight.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 14:48:55 -04:00
Josh Wright
b26382f427 polish: clamp park column width, prevent park name line wrap
All checks were successful
Build and Deploy / Build & Push (push) Successful in 59s
Column uses clamp(220px, 22%, 280px) — scales on small screens, caps at
280px on large ones. Park name gets whiteSpace:nowrap so it stays one line.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 14:45:28 -04:00
Josh Wright
56c7b90262 fix: responsive park column — percentage width, no nowrap, original font sizes
All checks were successful
Build and Deploy / Build & Push (push) Successful in 49s
Root cause was a hardcoded 240px column width + whiteSpace:nowrap that
prevented content from ever fitting on smaller displays. Now uses 25%
width so the column scales with the viewport, removed nowrap so text
wraps naturally when squeezed, and reverted clamp() back to fixed sizes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 14:41:46 -04:00
Josh Wright
5e4dd7403e fix: keep dot next to park name, scale all text with clamp() on small screens
All checks were successful
Build and Deploy / Build & Push (push) Successful in 50s
Removed flex:1/minWidth:0 from name span so dot stays snug to the right
of the park name. Removed flexShrink:0 from ride count so both sides can
compress. All text uses clamp() to scale proportionally with viewport.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 14:34:30 -04:00
Josh Wright
a717e122f0 fix: park name span flex-shrinks so dot and ride count never get crowded
All checks were successful
Build and Deploy / Build & Push (push) Successful in 55s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 14:29:52 -04:00
Josh Wright
732390425f Revert "fix: stack ride count below city/state to prevent overflow on small displays"
This reverts commit a1694668d9.
2026-04-05 14:29:34 -04:00
Josh Wright
a1694668d9 fix: stack ride count below city/state to prevent overflow on small displays
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 14:28:33 -04:00
12 changed files with 228 additions and 103 deletions

View File

@@ -27,6 +27,12 @@
--color-open-text: #4ade80; --color-open-text: #4ade80;
--color-open-hours: #bbf7d0; --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) ───────── */ /* ── Closing — amber (post-close buffer, rides still winding down) ───────── */
--color-closing-bg: #1a1100; --color-closing-bg: #1a1100;
--color-closing-border: #d97706; --color-closing-border: #d97706;
@@ -112,7 +118,7 @@
/* sm+: let rows breathe and grow with their content (cells are wide enough) */ /* sm+: let rows breathe and grow with their content (cells are wide enough) */
@media (min-width: 640px) { @media (min-width: 640px) {
.park-calendar-grid { .park-calendar-grid {
grid-auto-rows: minmax(96px, auto); grid-auto-rows: minmax(108px, auto);
} }
} }

View File

@@ -1,10 +1,12 @@
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, getApiId } from "@/lib/db";
import { getTodayLocal, isWithinOperatingWindow, getOperatingStatus } from "@/lib/env"; import { getTodayLocal, isWithinOperatingWindow, getOperatingStatus } from "@/lib/env";
import { fetchLiveRides } from "@/lib/scrapers/queuetimes"; import { fetchLiveRides } from "@/lib/scrapers/queuetimes";
import { fetchToday } from "@/lib/scrapers/sixflags";
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";
import type { DayData } from "@/lib/db";
interface PageProps { interface PageProps {
searchParams: Promise<{ week?: string }>; searchParams: Promise<{ week?: string }>;
@@ -49,6 +51,31 @@ export default async function HomePage({ searchParams }: PageProps) {
const db = openDb(); const db = openDb();
const data = getDateRange(db, weekStart, endDate); 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(); db.close();
const scrapedCount = Object.values(data).reduce( const scrapedCount = Object.values(data).reduce(
@@ -63,12 +90,16 @@ 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[] = []; let closingParkIds: string[] = [];
let openParkIds: string[] = [];
let weatherDelayParkIds: string[] = [];
if (weekDates.includes(today)) { if (weekDates.includes(today)) {
// Parks within operating hours right now (for open dot — independent of ride counts)
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 || !dayData.hoursLabel) return false;
return isWithinOperatingWindow(dayData.hoursLabel, p.timezone); return isWithinOperatingWindow(dayData.hoursLabel, p.timezone);
}); });
openParkIds = openTodayParks.map((p) => p.id);
closingParkIds = openTodayParks closingParkIds = openTodayParks
.filter((p) => { .filter((p) => {
const dayData = data[p.id]?.[today]; const dayData = data[p.id]?.[today];
@@ -77,17 +108,23 @@ export default async function HomePage({ searchParams }: PageProps) {
: false; : false;
}) })
.map((p) => p.id); .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( const results = await Promise.all(
openTodayParks.map(async (p) => { trackedParks.map(async (p) => {
const coasterSet = getCoasterSet(p.id, parkMeta); const coasterSet = getCoasterSet(p.id, parkMeta);
const result = await fetchLiveRides(QUEUE_TIMES_IDS[p.id], coasterSet, 300); 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; const coasterCount = result ? result.rides.filter((r) => r.isOpen && r.isCoaster).length : 0;
return { id: p.id, rideCount, coasterCount }; 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( 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( coasterCounts = Object.fromEntries(
results.filter(({ coasterCount }) => coasterCount > 0).map(({ id, coasterCount }) => [id, coasterCount]) results.filter(({ coasterCount }) => coasterCount > 0).map(({ id, coasterCount }) => [id, coasterCount])
@@ -103,7 +140,9 @@ export default async function HomePage({ searchParams }: PageProps) {
data={data} data={data}
rideCounts={rideCounts} rideCounts={rideCounts}
coasterCounts={coasterCounts} coasterCounts={coasterCounts}
openParkIds={openParkIds}
closingParkIds={closingParkIds} closingParkIds={closingParkIds}
weatherDelayParkIds={weatherDelayParkIds}
hasCoasterData={hasCoasterData} hasCoasterData={hasCoasterData}
scrapedCount={scrapedCount} scrapedCount={scrapedCount}
/> />

View File

@@ -5,6 +5,7 @@ import { PARK_MAP } from "@/lib/parks";
import { openDb, getParkMonthData, getApiId } from "@/lib/db"; import { openDb, getParkMonthData, getApiId } from "@/lib/db";
import { scrapeRidesForDay } from "@/lib/scrapers/sixflags"; import { scrapeRidesForDay } from "@/lib/scrapers/sixflags";
import { fetchLiveRides } from "@/lib/scrapers/queuetimes"; import { fetchLiveRides } from "@/lib/scrapers/queuetimes";
import { fetchToday } from "@/lib/scrapers/sixflags";
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";
import { ParkMonthCalendar } from "@/components/ParkMonthCalendar"; import { ParkMonthCalendar } from "@/components/ParkMonthCalendar";
@@ -44,7 +45,13 @@ export default async function ParkPage({ params, searchParams }: PageProps) {
const apiId = getApiId(db, id); const apiId = getApiId(db, id);
db.close(); 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; const parkOpenToday = todayData?.isOpen && todayData?.hoursLabel;
// ── Ride data: try live Queue-Times first, fall back to schedule ────────── // ── 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) { 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); ridesResult = await scrapeRidesForDay(apiId, today);
} }
@@ -157,6 +169,7 @@ export default async function ParkPage({ params, searchParams }: PageProps) {
<LiveRidePanel <LiveRidePanel
liveRides={liveRides} liveRides={liveRides}
parkOpenToday={!!parkOpenToday} parkOpenToday={!!parkOpenToday}
isWeatherDelay={isWeatherDelay}
/> />
) : ( ) : (
<RideList <RideList

View File

@@ -50,7 +50,9 @@ 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>;
openParkIds: string[];
closingParkIds: string[]; closingParkIds: string[];
weatherDelayParkIds: string[];
hasCoasterData: boolean; hasCoasterData: boolean;
scrapedCount: number; scrapedCount: number;
} }
@@ -63,7 +65,9 @@ export function HomePageClient({
data, data,
rideCounts, rideCounts,
coasterCounts, coasterCounts,
openParkIds,
closingParkIds, closingParkIds,
weatherDelayParkIds,
hasCoasterData, hasCoasterData,
scrapedCount, scrapedCount,
}: HomePageClientProps) { }: HomePageClientProps) {
@@ -231,7 +235,9 @@ export function HomePageClient({
today={today} today={today}
rideCounts={activeCounts} rideCounts={activeCounts}
coastersOnly={coastersOnly} coastersOnly={coastersOnly}
openParkIds={openParkIds}
closingParkIds={closingParkIds} closingParkIds={closingParkIds}
weatherDelayParkIds={weatherDelayParkIds}
/> />
</div> </div>
@@ -244,7 +250,9 @@ export function HomePageClient({
grouped={grouped} grouped={grouped}
rideCounts={activeCounts} rideCounts={activeCounts}
coastersOnly={coastersOnly} coastersOnly={coastersOnly}
openParkIds={openParkIds}
closingParkIds={closingParkIds} closingParkIds={closingParkIds}
weatherDelayParkIds={weatherDelayParkIds}
/> />
</div> </div>
</> </>

View File

@@ -6,9 +6,10 @@ import type { LiveRidesResult, LiveRide } from "@/lib/scrapers/queuetimes";
interface LiveRidePanelProps { interface LiveRidePanelProps {
liveRides: LiveRidesResult; liveRides: LiveRidesResult;
parkOpenToday: boolean; parkOpenToday: boolean;
isWeatherDelay?: boolean;
} }
export function LiveRidePanel({ liveRides, parkOpenToday }: LiveRidePanelProps) { export function LiveRidePanel({ liveRides, parkOpenToday, isWeatherDelay }: LiveRidePanelProps) {
const { rides } = liveRides; const { rides } = liveRides;
const hasCoasters = rides.some((r) => r.isCoaster); const hasCoasters = rides.some((r) => r.isCoaster);
const [coastersOnly, setCoastersOnly] = useState(false); const [coastersOnly, setCoastersOnly] = useState(false);
@@ -49,6 +50,19 @@ export function LiveRidePanel({ liveRides, parkOpenToday }: LiveRidePanelProps)
}}> }}>
{openRides.length} open {openRides.length} open
</div> </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={{ <div style={{
background: "var(--color-surface)", background: "var(--color-surface)",

View File

@@ -10,10 +10,12 @@ interface MobileCardListProps {
today: string; today: string;
rideCounts?: Record<string, number>; rideCounts?: Record<string, number>;
coastersOnly?: boolean; coastersOnly?: boolean;
openParkIds?: string[];
closingParkIds?: 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 ( 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]) => (
@@ -22,25 +24,17 @@ export function MobileCardList({ grouped, weekDates, data, today, rideCounts, co
<div style={{ <div style={{
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
gap: 10,
marginBottom: 10, marginBottom: 10,
paddingLeft: 2, paddingLeft: 2,
}}> }}>
<div style={{
width: 3,
height: 14,
borderRadius: 2,
background: "var(--color-region-accent)",
flexShrink: 0,
}} />
<span style={{ <span style={{
fontSize: "0.65rem", fontSize: "0.6rem",
fontWeight: 700, fontWeight: 700,
letterSpacing: "0.1em", letterSpacing: "0.14em",
textTransform: "uppercase", textTransform: "uppercase",
color: "var(--color-text-muted)", color: "var(--color-text-secondary)",
}}> }}>
{region} {region}
</span> </span>
</div> </div>
@@ -55,8 +49,10 @@ 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}
isOpen={openParkIds?.includes(park.id)}
isClosing={closingParkIds?.includes(park.id)} isClosing={closingParkIds?.includes(park.id)}
/> isWeatherDelay={weatherDelayParkIds?.includes(park.id)}
/>
))} ))}
</div> </div>
</div> </div>

View File

@@ -10,12 +10,14 @@ interface ParkCardProps {
today: string; today: string;
openRideCount?: number; openRideCount?: number;
coastersOnly?: boolean; coastersOnly?: boolean;
isOpen?: boolean;
isClosing?: boolean; isClosing?: boolean;
isWeatherDelay?: 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, 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 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);
@@ -29,6 +31,9 @@ export function ParkCard({ park, weekDates, parkData, today, openRideCount, coas
<div className="park-card" 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)",
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, borderRadius: 12,
overflow: "hidden", overflow: "hidden",
}}> }}>
@@ -38,6 +43,7 @@ export function ParkCard({ park, weekDates, parkData, today, openRideCount, coas
display: "flex", display: "flex",
alignItems: "flex-start", alignItems: "flex-start",
justifyContent: "space-between", justifyContent: "space-between",
flexWrap: "wrap",
gap: 12, gap: 12,
}}> }}>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
@@ -58,20 +64,20 @@ export function ParkCard({ park, weekDates, parkData, today, openRideCount, coas
</div> </div>
</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 ? ( {isOpenToday ? (
<div style={{ <div style={{
background: isClosing ? "var(--color-closing-bg)" : "var(--color-open-bg)", background: isWeatherDelay ? "var(--color-weather-bg)" : isClosing ? "var(--color-closing-bg)" : "var(--color-open-bg)",
border: `1px solid ${isClosing ? "var(--color-closing-border)" : "var(--color-open-border)"}`, border: `1px solid ${isWeatherDelay ? "var(--color-weather-border)" : 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: 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", whiteSpace: "nowrap",
letterSpacing: "0.03em", letterSpacing: "0.03em",
}}> }}>
{isClosing ? "Closing" : "Open today"} {isWeatherDelay ? "⛈ Weather Delay" : isClosing ? "Closing" : "Open today"}
</div> </div>
) : ( ) : (
<div style={{ <div style={{
@@ -87,7 +93,17 @@ export function ParkCard({ park, weekDates, parkData, today, openRideCount, coas
Closed today Closed today
</div> </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={{ <div style={{
fontSize: "0.65rem", fontSize: "0.65rem",
color: isClosing ? "var(--color-closing-hours)" : "var(--color-open-hours)", color: isClosing ? "var(--color-closing-hours)" : "var(--color-open-hours)",

View File

@@ -167,7 +167,7 @@ export function ParkMonthCalendar({ parkId, year, month, monthData, today, timez
borderLeft: isToday ? "2px solid var(--color-today-border)" : "none", borderLeft: isToday ? "2px solid var(--color-today-border)" : "none",
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
gap: 8, gap: 6,
}}> }}>
{/* Date number */} {/* Date number */}
<span style={{ <span style={{
@@ -203,16 +203,16 @@ export function ParkMonthCalendar({ parkId, year, month, monthData, today, timez
background: "var(--color-ph-bg)", background: "var(--color-ph-bg)",
border: "1px solid var(--color-ph-border)", border: "1px solid var(--color-ph-border)",
borderRadius: 5, borderRadius: 5,
padding: "3px 6px", padding: "8px 6px",
textAlign: "center", textAlign: "center",
}}> }}>
<div style={{ fontSize: "0.6rem", fontWeight: 700, color: "var(--color-ph-label)", textTransform: "uppercase", letterSpacing: "0.05em" }}> <div style={{ fontSize: "0.6rem", fontWeight: 700, color: "var(--color-ph-label)", textTransform: "uppercase", letterSpacing: "0.05em" }}>
Passholder Passholder
</div> </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} {dayData.hoursLabel}
</div> </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} {tzAbbr}
</div> </div>
</div> </div>
@@ -221,13 +221,13 @@ export function ParkMonthCalendar({ parkId, year, month, monthData, today, timez
background: "var(--color-open-bg)", background: "var(--color-open-bg)",
border: "1px solid var(--color-open-border)", border: "1px solid var(--color-open-border)",
borderRadius: 5, borderRadius: 5,
padding: "3px 6px", padding: "8px 6px",
textAlign: "center", textAlign: "center",
}}> }}>
<div style={{ fontSize: "0.65rem", color: "var(--color-open-hours)" }}> <div style={{ fontSize: "0.65rem", color: "var(--color-open-hours)" }}>
{dayData.hoursLabel} {dayData.hoursLabel}
</div> </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} {tzAbbr}
</div> </div>
</div> </div>

View File

@@ -12,7 +12,9 @@ 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 openParkIds?: string[];
closingParkIds?: string[];
weatherDelayParkIds?: string[];
} }
const DOW = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; const DOW = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
@@ -46,7 +48,7 @@ function DayCell({
verticalAlign: "middle", verticalAlign: "middle",
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: 72,
background: isWeekend ? "var(--color-weekend-header)" : "transparent", background: isWeekend ? "var(--color-weekend-header)" : "transparent",
transition: "background 120ms ease", transition: "background 120ms ease",
}; };
@@ -69,7 +71,7 @@ function DayCell({
if (dayData.specialType === "passholder_preview") { if (dayData.specialType === "passholder_preview") {
return ( return (
<td style={{ ...base, padding: 4 }}> <td style={{ ...base, padding: 6 }}>
<div style={{ <div style={{
background: "var(--color-ph-bg)", background: "var(--color-ph-bg)",
border: "1px solid var(--color-ph-border)", border: "1px solid var(--color-ph-border)",
@@ -164,17 +166,16 @@ function RegionHeader({ region, colSpan }: { region: string; colSpan: number })
padding: "10px 14px 6px", padding: "10px 14px 6px",
background: "var(--color-region-bg)", background: "var(--color-region-bg)",
borderBottom: "1px solid var(--color-border-subtle)", borderBottom: "1px solid var(--color-border-subtle)",
borderLeft: "3px solid var(--color-region-accent)",
}} }}
> >
<span style={{ <span style={{
fontSize: "0.65rem", fontSize: "0.6rem",
fontWeight: 700, fontWeight: 700,
letterSpacing: "0.1em", letterSpacing: "0.14em",
textTransform: "uppercase", textTransform: "uppercase",
color: "var(--color-text-muted)", color: "var(--color-text-secondary)",
}}> }}>
{region} {region}
</span> </span>
</td> </td>
</tr> </tr>
@@ -189,7 +190,9 @@ function ParkRow({
parkData, parkData,
rideCounts, rideCounts,
coastersOnly, coastersOnly,
openParkIds,
closingParkIds, closingParkIds,
weatherDelayParkIds,
}: { }: {
park: Park; park: Park;
parkIdx: number; parkIdx: number;
@@ -198,11 +201,15 @@ function ParkRow({
parkData: Record<string, DayData>; parkData: Record<string, DayData>;
rideCounts?: Record<string, number>; rideCounts?: Record<string, number>;
coastersOnly?: boolean; coastersOnly?: boolean;
openParkIds?: string[];
closingParkIds?: string[]; closingParkIds?: string[];
weatherDelayParkIds?: 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 isOpen = openParkIds?.includes(park.id) ?? false;
const isClosing = closingParkIds?.includes(park.id) ?? false; const isClosing = closingParkIds?.includes(park.id) ?? false;
const isWeatherDelay = weatherDelayParkIds?.includes(park.id) ?? false;
return ( return (
<tr <tr
className="park-row" className="park-row"
@@ -216,7 +223,9 @@ function ParkRow({
padding: 0, 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", borderLeft: isOpen
? `3px solid ${isWeatherDelay ? "var(--color-weather-border)" : isClosing ? "var(--color-closing-border)" : "var(--color-open-border)"}`
: "3px solid transparent",
verticalAlign: "middle", verticalAlign: "middle",
background: rowBg, background: rowBg,
transition: "background 120ms ease", transition: "background 120ms ease",
@@ -228,30 +237,23 @@ function ParkRow({
padding: "10px 14px", padding: "10px 14px",
gap: 10, gap: 10,
}}> }}>
<div style={{ minWidth: 0, flex: 1 }}> <div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: 6, minWidth: 0 }}> <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<span style={{ fontWeight: 500, fontSize: "clamp(0.68rem, 1.1vw, 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} {park.name}
</span> </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>
<div style={{ fontSize: "clamp(0.58rem, 0.9vw, 0.7rem)", color: "var(--color-text-muted)", marginTop: 2 }}> <div style={{ fontSize: "0.7rem", color: "var(--color-text-muted)", marginTop: 2 }}>
{park.location.city}, {park.location.state} {park.location.city}, {park.location.state}
</div> </div>
</div> </div>
{rideCounts?.[park.id] !== undefined && ( {isWeatherDelay && (
<div style={{ fontSize: "clamp(0.58rem, 0.95vw, 0.72rem)", color: isClosing ? "var(--color-closing-hours)" : "var(--color-open-hours)", fontWeight: 600, flexShrink: 0, textAlign: "right" }}> <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]} {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
@@ -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 today = getTodayLocal();
const parsedDates = weekDates.map(parseDate); 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 const colSpan = weekDates.length + 1; // park col + 7 day cols
return ( return (
<div style={{ overflowX: "auto", overflowY: "visible" }}> <div style={{ overflowX: "auto", overflowY: "visible", paddingRight: 16 }}>
<table style={{ <table style={{
borderCollapse: "collapse", borderCollapse: "collapse",
width: "100%", width: "100%",
@@ -388,7 +390,9 @@ 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}
openParkIds={openParkIds}
closingParkIds={closingParkIds} closingParkIds={closingParkIds}
weatherDelayParkIds={weatherDelayParkIds}
/> />
))} ))}
</Fragment> </Fragment>

View File

@@ -46,12 +46,10 @@ export function upsertDay(
hoursLabel?: string, hoursLabel?: string,
specialType?: string specialType?: string
) { ) {
// Today and past dates: INSERT new rows freely, but NEVER overwrite existing records. // Today and future dates: full upsert — hours can change (e.g. weather delays,
// Once an operating day begins the API drops that date from its response, so a // early closures) and the dateless API endpoint now returns today's live data.
// 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.
// //
// 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(` db.prepare(`
INSERT INTO park_days (park_id, date, is_open, hours_label, special_type, scraped_at) INSERT INTO park_days (park_id, date, is_open, hours_label, special_type, scraped_at)
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?)
@@ -60,7 +58,7 @@ export function upsertDay(
hours_label = excluded.hours_label, hours_label = excluded.hours_label,
special_type = excluded.special_type, special_type = excluded.special_type,
scraped_at = excluded.scraped_at 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()); `).run(parkId, date, isOpen ? 1 : 0, hoursLabel ?? null, specialType ?? null, new Date().toISOString());
} }

View File

@@ -166,13 +166,48 @@ function apiDateToIso(apiDate: string): string {
return `${yyyy}-${mm}-${dd}`; 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), * The monthly API endpoint (`?date=YYYYMM`) may not include today; use
* returning only tomorrow onwards. When the requested date is missing, we fall * `fetchToday(apiId)` to get today's park hours directly. The fallback
* back to the nearest available upcoming date in the same month's response so * chain here will find the nearest upcoming date if an exact match is missing.
* the UI can still show a useful (if approximate) schedule.
* *
* Returns null if no ride data could be found at all (API error, pre-season, * Returns null if no ride data could be found at all (API error, pre-season,
* no venues in response). * no venues in response).
@@ -286,30 +321,7 @@ export async function scrapeMonth(
const data = await fetchApi(url); const data = await fetchApi(url);
return data.dates.map((d): DayResult => { return data.dates.map(parseApiDay);
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 };
});
} }
/** /**

View File

@@ -9,7 +9,7 @@
import { openDb, upsertDay, getApiId, isMonthScraped } from "../lib/db"; import { openDb, upsertDay, getApiId, isMonthScraped } from "../lib/db";
import { PARKS } from "../lib/parks"; 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 { readParkMeta, writeParkMeta, areCoastersStale } from "../lib/park-meta";
import { scrapeRcdbCoasters } from "../lib/scrapers/rcdb"; import { scrapeRcdbCoasters } from "../lib/scrapers/rcdb";
@@ -100,6 +100,25 @@ async function main() {
console.log(`\n ${totalFetched} fetched ${totalSkipped} skipped ${totalErrors} errors`); console.log(`\n ${totalFetched} fetched ${totalSkipped} skipped ${totalErrors} errors`);
if (totalErrors > 0) console.log(" Re-run to retry failed months."); 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(); db.close();
// ── RCDB coaster scrape (30-day staleness) ──────────────────────────────── // ── RCDB coaster scrape (30-day staleness) ────────────────────────────────