Compare commits

..

78 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
Josh Wright
f809f9171b fix: scale park column text with viewport width using clamp()
All checks were successful
Build and Deploy / Build & Push (push) Successful in 56s
Park name, city/state, and ride count all use clamp() so they shrink
proportionally on smaller displays without truncating or overflowing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 14:25:52 -04:00
Josh Wright
fa269db3ef fix: truncate park name with ellipsis to prevent clash with dot and ride count
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 14:22:17 -04:00
Josh Wright
ef3e57bd5a fix: prevent ride count overflow in park column on smaller displays
All checks were successful
Build and Deploy / Build & Push (push) Successful in 54s
Let the park name side flex-shrink (minWidth:0, flex:1) so the ride count
always fits in the row without overflowing its column.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 14:12:33 -04:00
Josh Wright
ed6d09f3bc Revert "fix: move ride count below park name to prevent overflow on small displays"
This reverts commit e2498af481.
2026-04-05 14:12:15 -04:00
Josh Wright
e2498af481 fix: move ride count below park name to prevent overflow on small displays
All checks were successful
Build and Deploy / Build & Push (push) Successful in 51s
Removes the space-between flex layout that pushed the count to the right
edge of a fixed-width column. Now stacks under city/state so it always
fits within the park name column regardless of screen size.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 14:10:45 -04:00
Josh Wright
d7f046a4d6 feat: double-tap refresh at park opening — mark open, then fetch ride counts
All checks were successful
Build and Deploy / Build & Push (push) Successful in 50s
Fire two refreshes per park: one at opening time to flip the open indicator,
and one 30s later to pick up queue-times ride counts once the API updates.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 11:07:14 -04:00
Josh Wright
7c00ae5000 feat: schedule targeted refresh at each park's exact opening time
In addition to the 2-minute polling interval, compute milliseconds until
each park's opening time (from hoursLabel + park timezone) and schedule
a setTimeout to fire 30s after opening. This ensures the open indicator
and ride counts appear immediately rather than waiting up to 2 minutes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 11:06:03 -04:00
Josh Wright
7ee28c7ca3 feat: auto-refresh homepage data every 2 minutes on current week
All checks were successful
Build and Deploy / Build & Push (push) Successful in 52s
Uses router.refresh() to re-run server data fetching (ride counts, open
status) without a full page reload. Only runs when viewing the current
week — no need to poll for past/future weeks.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 11:00:14 -04:00
Josh Wright
e7dac31d22 polish: increase gap between date number and hours pill in calendar cells
All checks were successful
Build and Deploy / Build & Push (push) Successful in 50s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 09:43:09 -04:00
Josh Wright
c25dafb14c fix: restore full-width pills in park calendar cells, text centered inside
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 09:40:18 -04:00
Josh Wright
05f8994966 polish: center hours pill and date number in desktop calendar cells
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 09:37:19 -04:00
Josh Wright
040c1e4d70 fix: responsive park month calendar — dot-only on mobile, full pill on desktop
All checks were successful
Build and Deploy / Build & Push (push) Successful in 53s
Mobile cells (~55px wide) can't fit hours text legibly, so show only a
colored dot when open and a dash when no data. Desktop restores the full
pill (hours + tz abbreviation) via Tailwind responsive classes. Row height
is controlled by a new .park-calendar-grid CSS class: 72px fixed on mobile,
minmax(96px, auto) on sm+, keeping desktop cells from looking cramped.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 09:31:48 -04:00
Josh Wright
a31dda4e9e fix: uniform cell heights in park month calendar
All checks were successful
Build and Deploy / Build & Push (push) Successful in 51s
Root cause: each week row was its own CSS Grid container, so rows
with open-day pills (hours + separate timezone line) grew taller than
closed rows, making the calendar column lines look staggered/slanted.

- Flatten all day cells into a single grid with gridAutoRows: 76
  so every row is exactly the same fixed height
- All cells get overflow: hidden so content can never push height
- Compact the status pill to a single line (hours + tz inline,
  truncated with ellipsis) — the stacked two-line pill was the
  primary height expander on narrow mobile columns
- Row/column border logic moves from week-wrapper divs to individual
  cell borderRight / borderBottom properties
- Nav link touch targets: padding 6×14 → 10×16, minWidth: 44px

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 09:20:36 -04:00
Josh Wright
b276cc9948 polish: mobile view layout and usability improvements
All checks were successful
Build and Deploy / Build & Push (push) Successful in 53s
Header:
- Hide "X of Y parks open this week" badge on mobile (hidden sm:inline-flex)
  — title + Coaster Mode button fit cleanly on a 390px screen

WeekNav:
- Arrow button padding 6px 14px → 10px 16px, minWidth: 44px for proper
  touch targets (Apple HIG recommends 44px minimum)
- Date label minWidth 200px → 140px, prevents crowding on small screens

ParkCard:
- Name container: flex: 1, minWidth: 0 so long park names don't push
  the status badge off-screen; name wraps naturally instead of overflowing
- Timezone abbreviation: opacity: 0.6 → color: var(--color-text-dim),
  semantic dimming instead of opacity for better accessibility
- Passholder label: 0.58rem → 0.65rem (was below WCAG minimum)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 09:11:34 -04:00
Josh Wright
53297a7cff feat: amber indicator during post-close wind-down buffer
All checks were successful
Build and Deploy / Build & Push (push) Successful in 2m22s
Parks in the 1-hour buffer after scheduled close now show amber instead
of green: the dot on the desktop calendar turns yellow, and the mobile
card badge changes from "Open today" (green) to "Closing" (amber).

- getOperatingStatus() replaces isWithinOperatingWindow's inline logic,
  returning "open" | "closing" | "closed"; isWithinOperatingWindow now
  delegates to it so all callers are unchanged
- closingParkIds[] is computed server-side and threaded through
  HomePageClient → WeekCalendar/MobileCardList → ParkRow/ParkCard
- New --color-closing-* CSS variables mirror the green palette in amber

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 09:06:45 -04:00
Josh Wright
090f4d876d fix: "← Calendar" returns to the previously viewed week
All checks were successful
Build and Deploy / Build & Push (push) Successful in 1m3s
HomePageClient writes the current weekStart to localStorage("lastWeek")
whenever it changes. BackToCalendarLink (new client component) reads
that value on mount and builds the href — falling back to "/" if nothing
is stored (e.g. direct link to a park page).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 08:59:14 -04:00
Josh Wright
5b575f962e feat: persistent Coaster Mode toggle in header top-right
All checks were successful
Build and Deploy / Build & Push (push) Successful in 53s
- Moves the coaster toggle out of WeekNav and into the homepage header
  top-right as "🎢 Coaster Mode", alongside the parks open badge
- State is stored in localStorage ("coasterMode") so the preference
  persists across sessions and page refreshes
- Dropped the ?coasters=1 URL param entirely; the server always fetches
  both rideCounts and coasterCounts, and HomePageClient picks which to
  display client-side — no flash or server round-trip on toggle
- Individual park pages: LiveRidePanel reads localStorage on mount and
  pre-selects the Coasters Only filter when Coaster Mode is active
- Extracted HomePageClient (client component) to own the full homepage
  UI; page.tsx is now pure data-fetching

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 08:36:38 -04:00
Josh Wright
8c3841d9a5 polish: move queue-times attribution to Rides section heading
All checks were successful
Build and Deploy / Build & Push (push) Successful in 51s
Attribution now sits on the right end of the "RIDES · Live" bar as a
subtle "via queue-times.com" link, making it clear the live ride data
(not the whole site) is sourced from Queue-Times. Removed the orphaned
attribution block from the bottom of LiveRidePanel.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 08:27:45 -04:00
Josh Wright
fd45309891 polish: clarify parks open badge; improve timezone display
All checks were successful
Build and Deploy / Build & Push (push) Successful in 51s
- "parks open" → "parks open this week" for clarity
- Week calendar cells: stack hours above tz abbreviation (smaller,
  dimmer) instead of inline to avoid overflow in tight 130px columns
- Mobile park cards: tz abbreviation inline but smaller/dimmer (60% opacity)
- Month calendar: same two-line stacking in compact day cells

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 08:22:06 -04:00
Josh Wright
c4c86a3796 fix: use park timezone for operating window check; show tz in hours
All checks were successful
Build and Deploy / Build & Push (push) Successful in 4m22s
- isWithinOperatingWindow now accepts an IANA timezone and reads the
  current time in the park's local timezone via Intl.DateTimeFormat,
  fixing false positives when the server runs in UTC but parks store
  hours in local time (e.g. Pacific parks showing open at 6:50 AM EDT)
- Remove the 1-hour pre-open buffer so parks are not marked open before
  their doors actually open; retain the 1-hour post-close grace period
- Add getTimezoneAbbr() helper to derive the short tz label (EDT, PDT…)
- All hours labels now display with the local timezone abbreviation
  (e.g. "10am – 6pm PDT") in WeekCalendar, ParkCard, and ParkMonthCalendar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 08:12:19 -04:00
7456ead430 feat: coaster filter toggle on homepage
All checks were successful
Build and Deploy / Build & Push (push) Successful in 1m14s
- 🎢 Coasters button in nav bar (URL-driven: ?coasters=1)
- When active, swaps ride counts for coaster counts per park
- Label switches between "X rides operating" / "X coasters operating"
- Arrow key navigation preserves coaster filter state
- Only shown when coaster data exists in park-meta

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 21:03:00 -04:00
f1fec2355c polish: ride count copy, open indicator, and badge sizing
All checks were successful
Build and Deploy / Build & Push (push) Successful in 1m5s
- "X rides open" → "X rides operating" (desktop + mobile)
- Green glowing dot next to park name when actively operating
- Hours text in calendar cells: larger (0.78rem) and bolder (600)
- Parks open badge: green tint when parks are open, larger text

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 20:54:14 -04:00
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
fdea8443fb fix: restore arrow key week navigation; improve empty state copy
All checks were successful
Build and Deploy / Build & Push (push) Successful in 1m6s
- WeekNav: add keydown listener for ArrowLeft/ArrowRight week nav
- EmptyState: replace dev-facing "No data scraped yet" + npm commands
  with customer-friendly "Schedule not available yet" message

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 20:07:43 -04:00
6bb35d468f security: add headers, fetch timeouts, Retry-After cap, env validation
All checks were successful
Build and Deploy / Build & Push (push) Successful in 3m50s
- next.config.ts: CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy
- sixflags.ts: cap Retry-After at 5 min; add 15s AbortSignal.timeout()
- queuetimes.ts: add 10s AbortSignal.timeout()
- rcdb.ts: add 15s AbortSignal.timeout()
- lib/env.ts: parseStalenessHours() guards against NaN from invalid env vars
- db.ts + park-meta.ts: use parseStalenessHours() for staleness window config

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 17:13:01 -04:00
e1b0e5e44d chore: remove redundant 'done' line from discover output
All checks were successful
Build and Deploy / Build & Push (push) Successful in 3m27s
The → ID line already confirms success.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 16:59:11 -04:00
edd044a1f8 docs: fix volume name to root_park_data
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-04 16:54:36 -04:00
eeed646203 docs: rewrite deployment section for two-image setup
All checks were successful
Build and Deploy / Build & Push (push) Successful in 52s
- Document web/:web and scraper/:scraper image split
- Full first-time setup flow: pull → discover → RCDB IDs → scrape → up
- Document all scraper env vars (TZ, PARK_HOURS_STALENESS_HOURS, COASTER_STALENESS_HOURS)
- Add manual scrape commands, update workflow, add npm test to dev section

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 16:48:22 -04:00
eeb4a649c1 feat: split web and scraper into separate Docker images
All checks were successful
Build and Deploy / Build & Push (push) Successful in 3m4s
- Dockerfile: replace single runner stage with web + scraper named targets
  - web: Next.js standalone only — no playwright, tsx, or scripts
  - scraper: scripts/lib/node_modules/playwright only — no Next.js output
- docker-compose.yml: each service pulls its dedicated image tag
- .gitea/workflows/deploy.yml: build both targets on push to main
- lib/db.ts: STALE_AFTER_MS reads PARK_HOURS_STALENESS_HOURS env var (default 72h)
- lib/park-meta.ts: COASTER_STALE_MS reads COASTER_STALENESS_HOURS env var (default 720h)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 16:40:31 -04:00
766fc296a1 fix: isCoaster typo in top-level rides loop; simplify test structure
All checks were successful
Build and Deploy / Build & Push (push) Successful in 3m0s
- isCoaster → isCoasterMatch on line 109 (missed rename causing runtime crash
  which returned null from fetchLiveRides, breaking the entire ride panel)
- Rewrite test as two flat arrays: SHOULD_MATCH and SHOULD_NOT_MATCH pairs,
  each with the QT name, RCDB name, and park for context

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 15:49:47 -04:00
8324f31972 fix: correct import paths for coaster-match module
Imports must appear before other statements in ES modules.
Also drop the explicit .ts extension from import paths — Next.js
bundler resolves them without it, and the extension after const
was causing the module to silently fail in the app.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 15:46:07 -04:00
9cac86d241 test: add coaster name matching test suite
Extract matching logic into lib/coaster-match.ts (isCoasterMatch + normalizeForMatch)
so it can be imported by both the scraper and tests without duplication.

Add tests/coaster-matching.test.ts covering all known match/false-positive cases:
- Trademark symbols, leading THE, possessives, punctuation
- Subtitle variants in both directions (Apocalypse, New Revolution - Classic)
- Space-split brand words (BAT GIRL vs Batgirl)
- 4D subtitle extension (THE JOKER™ 4D Free Fly Coaster vs Joker)
- False positives: Joker y Harley Quinn, conjunction connectors

Run with: npm test

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 15:43:20 -04:00
dc4fbeb7ec chore: ignore .claude/ directory
All checks were successful
Build and Deploy / Build & Push (push) Successful in 3m36s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 15:26:00 -04:00
c1e42d6aa1 fix: truncate long ride names with tooltip instead of wrapping
Restores fixed-height ride rows with ellipsis truncation.
Adds title attribute so hovering shows the full name in a native tooltip.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 15:25:01 -04:00
e9da6f3120 fix: robust coaster matching + dark carnival color scheme
Matching fixes:
- normalize() now strips all non-word/non-space chars via [^\w\s] instead of
  a hand-rolled list, catching !, curly apostrophe (U+2019), and any future edge cases
- Add isCoaster() helper with prefix matching (min 5 chars) to handle subtitle
  mismatches in either direction (e.g. "Apocalypse" vs "Apocalypse the Ride",
  "The New Revolution - Classic" vs "New Revolution")
- Fix top-level rides loop which still used coasterNames.has(normalize()) instead
  of isCoaster() — this was the recurring bug causing top-level rides to miss

UI:
- Dark neutral base (#111) replacing cold navy and muddy purple
- Neon accent palette: hot pink, electric green, vivid yellow, cyan
- Park page max-width 960→1280px, calendar cells 72→96px tall
- Scrollbar accent matches theme

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 15:22:59 -04:00
bad366d5ea revert: remove park-meta.json copy from Dockerfile
All checks were successful
Build and Deploy / Build & Push (push) Successful in 3m34s
Volume mount hides image-layer files on existing deployments anyway.
Deploy manually: curl -o park-meta.json <gitea-raw-url>

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 14:02:39 -04:00
9700d0bd9a feat: RCDB-backed roller coaster filter with fuzzy name matching
All checks were successful
Build and Deploy / Build & Push (push) Successful in 2m54s
- Add lib/park-meta.ts to manage data/park-meta.json (rcdb_id + coaster lists)
- Add lib/scrapers/rcdb.ts to scrape operating coaster names from RCDB park pages
- discover.ts now seeds park-meta.json with skeleton entries for all parks
- scrape.ts now refreshes RCDB coaster lists (30-day staleness) for parks with rcdb_id set
- fetchLiveRides() accepts a coasterNames Set; isCoaster uses normalize() on both sides
  to handle trademark symbols, 'THE ' prefixes, and punctuation differences between
  Queue-Times and RCDB names — applies correctly to both land rides and top-level rides
- Commit park-meta.json so it ships in the Docker image (fresh volumes get it automatically)
- Update .gitignore / .dockerignore to exclude only *.db files, not all of data/
- Dockerfile copies park-meta.json into image before VOLUME declaration
- README: document coaster filter setup and correct staleness window (72h not 7d)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 13:49:49 -04:00
819e716197 feat: coaster-only toggle on live ride status panel
Queue-Times groups rides into lands (e.g. "Coasters", "Family", "Kids").
Capture that categorisation in LiveRide.isCoaster and surface it as a
toggle in the new LiveRidePanel client component.

- lib/scrapers/queuetimes.ts: add isCoaster: boolean to LiveRide,
  derived from land.name.toLowerCase().includes("coaster")
- components/LiveRidePanel.tsx: client component replacing the old
  inline LiveRideList; adds a "🎢 Coasters only" pill toggle that
  filters the grid; toggle only appears when the park has coaster-
  categorised rides; amber when active, muted when inactive
- app/park/[id]/page.tsx: swap LiveRideList for LiveRidePanel,
  remove now-dead LiveRideList/LiveRideRow functions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 12:56:20 -04:00
da083c125c feat: automated nightly scraper + housekeeping
All checks were successful
Build and Deploy / Build & Push (push) Successful in 3m11s
Scraper automation (docker-compose):
- Add scraper service to docker-compose.yml using the same image and
  shared park_data volume; overrides CMD to run scrape-schedule.sh
- scripts/scrape-schedule.sh: runs an initial scrape on container start,
  then sleeps until 3:00 AM (respects TZ env var) and repeats nightly;
  logs timestamps and next-run countdown; non-fatal on scrape errors

Staleness window: 7 days → 72 hours in lib/db.ts so data refreshes
more frequently with the automated schedule in place

Remove favicon: delete app/icon.tsx and public/logo.svg

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 12:47:14 -04:00
20f1058e9e fix: protect today's record from scrape overwrites
Change upsertDay WHERE guard from >= to > date('now') so today is
treated identically to past dates. Once a park's operating day starts
the API drops that date, making it appear closed. The record written
when the date was still future is the correct one and must be preserved.

Only strictly future dates (> today) are now eligible for upserts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 12:42:03 -04:00
5ea2dafc0e fix: preserve historical day records, skip scraping past months
upsertDay: add WHERE park_days.date >= date('now') to the ON CONFLICT
DO UPDATE clause. Past dates now behave as INSERT OR IGNORE — new rows
are written freely but existing historical records are never overwritten.
The API stops returning elapsed dates, so the DB row is the permanent
source of truth for any date that has already occurred.

isMonthScraped: months whose last day is before today are permanently
skipped regardless of staleness age. The API has no data for past months
so re-scraping them wastes API calls and cannot improve the records.
Current and future months continue to use the 7-day staleness window.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 12:37:27 -04:00
d4c8046515 improve: redesign mobile card layout for usability
All checks were successful
Build and Deploy / Build & Push (push) Successful in 3m24s
Replace the cramped 7-column day grid with a clean open-days list.
Each card now shows:
- Park name + "Open today" / "Closed today" badge in the header
- One row per open day (Today, Monday, Friday...) with full hours
- Today row highlighted in amber; passholder days labeled inline
- Whole card is a tap target linking to the park detail page

Also:
- Hide the legend below sm breakpoint (not needed on phones)
- Reduce horizontal padding to 16px on mobile (was 24px)
- Tighten MobileCardList vertical spacing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 12:33:02 -04:00
b4183507a8 chore: remove logo from header and README
All checks were successful
Build and Deploy / Build & Push (push) Successful in 3m3s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 12:28:39 -04:00
81ff6ea659 feat: roller coaster logo and working favicon
- public/logo.svg: amber coaster silhouette (lift hill + vertical loop +
  camelback) on transparent background; used in header and README
- app/icon.tsx: PNG favicon via Next.js ImageResponse (works in all
  browsers including Safari); renders simplified hill + loop on dark
  rounded-square background
- app/page.tsx: logo img added next to "Thoosie Calendar" title in header
- README.md: logo displayed at top of document
- Remove app/icon.svg (replaced by icon.tsx → PNG)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 12:26:45 -04:00
8b1c8dcb29 chore: rebrand to Thoosie Calendar, add favicon
- Rename app title and header from "Six Flags Calendar" to "Thoosie Calendar"
- Update layout metadata title and description
- Update README title
- Add app/icon.svg favicon: amber T on dark background, picked up
  automatically by Next.js App Router

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 12:18:22 -04:00
e7b72ff95b feat: add live ride status via Queue-Times.com API
All checks were successful
Build and Deploy / Build & Push (push) Successful in 2m51s
Park detail pages now show real-time ride open/closed status and wait
times sourced from Queue-Times.com (updates every 5 min) when a park
is operating. Falls back to the Six Flags schedule API for off-hours
or parks without a Queue-Times mapping.

- lib/queue-times-map.ts: maps all 24 park IDs to Queue-Times park IDs
- lib/scrapers/queuetimes.ts: fetches and parses queue_times.json with
  5-minute ISR cache; returns LiveRidesResult with isOpen + waitMinutes
- app/park/[id]/page.tsx: tries Queue-Times first; renders LiveRideList
  with Live badge and per-ride wait times; falls back to RideList for
  schedule data when live data is unavailable
- README: documents two-tier ride status approach

Attribution: Queue-Times.com (displayed in UI per their API terms)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 12:15:36 -04:00
ba8cd46e75 docs: update README with current feature set, remove CI/CD section
- Add park detail pages and ride status to description
- Replace flat park list with regional table
- Add debug command documentation
- Remove CI/CD section (Gitea Actions config docs)
- Clean up deployment section

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 12:03:32 -04:00
34 changed files with 2593 additions and 647 deletions

View File

@@ -2,10 +2,9 @@
.gitea
.next
node_modules
data/
*.db
*.db-shm
*.db-wal
data/*.db
data/*.db-shm
data/*.db-wal
.env*
npm-debug.log*
.DS_Store

View File

@@ -4,26 +4,14 @@ on:
push:
branches:
- main
tags:
- 'v*'
jobs:
build-push:
name: Build & Push
runs-on: ubuntu-latest
if: github.event_name == 'push'
steps:
- uses: actions/checkout@v4
- name: Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ vars.REGISTRY }}/${{ gitea.repository_owner }}/sixflagssupercalendar
tags: |
type=semver,pattern={{version}}
type=raw,value=latest,enable={{is_default_branch}}
- name: Log in to Gitea registry
uses: docker/login-action@v3
with:
@@ -31,10 +19,18 @@ jobs:
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and push
- name: Build and push web image
uses: docker/build-push-action@v6
with:
context: .
target: web
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
tags: ${{ vars.REGISTRY }}/${{ gitea.repository_owner }}/sixflagssupercalendar:web
- name: Build and push scraper image
uses: docker/build-push-action@v6
with:
context: .
target: scraper
push: true
tags: ${{ vars.REGISTRY }}/${{ gitea.repository_owner }}/sixflagssupercalendar:scraper

7
.gitignore vendored
View File

@@ -21,13 +21,18 @@
.DS_Store
*.pem
# Claude Code
.claude/
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# scraped data — local only, not committed
/data/
/data/*.db
/data/*.db-shm
/data/*.db-wal
# env files
.env*

View File

@@ -1,4 +1,4 @@
# Stage 1: Install all dependencies (dev included — scripts need tsx + playwright)
# Stage 1: Install all dependencies (dev included — scraper needs tsx + playwright)
FROM node:22-bookworm-slim AS deps
RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ && \
rm -rf /var/lib/apt/lists/*
@@ -11,47 +11,60 @@ FROM deps AS builder
COPY . .
RUN npm run build
# Stage 3: Production runner
FROM node:22-bookworm-slim AS runner
# ── web ──────────────────────────────────────────────────────────────────────
# Minimal Next.js runner. No playwright, no tsx, no scripts.
# next build --output standalone bundles its own node_modules (incl. better-sqlite3).
FROM node:22-bookworm-slim AS web
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
# Store Playwright browser in a predictable path inside the image
ENV PLAYWRIGHT_BROWSERS_PATH=/app/.playwright
# Create non-root user before copying files so --chown works
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
# Copy Next.js standalone output
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
# Copy scripts + library source (needed for npm run discover/scrape via tsx)
COPY --from=builder --chown=nextjs:nodejs /app/scripts ./scripts
COPY --from=builder --chown=nextjs:nodejs /app/lib ./lib
COPY --from=builder --chown=nextjs:nodejs /app/package.json ./package.json
COPY --from=builder --chown=nextjs:nodejs /app/tsconfig.json ./tsconfig.json
# Replace standalone's minimal node_modules with full deps
# (includes tsx, playwright, and all devDependencies)
COPY --from=deps --chown=nextjs:nodejs /app/node_modules ./node_modules
# Install Playwright Chromium browser + all required system libraries.
# Runs as root so apt-get works; browser lands in PLAYWRIGHT_BROWSERS_PATH.
RUN npx playwright install --with-deps chromium && \
chown -R nextjs:nodejs /app/.playwright
# SQLite data directory — mount a named volume here for persistence
RUN mkdir -p /app/data && chown nextjs:nodejs /app/data
VOLUME ["/app/data"]
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]
# ── scraper ───────────────────────────────────────────────────────────────────
# Scraper-only image. No Next.js output. Runs on a nightly schedule via
# scripts/scrape-schedule.sh. Staleness windows are configurable via env vars:
# PARK_HOURS_STALENESS_HOURS (default: 72)
# COASTER_STALENESS_HOURS (default: 720 = 30 days)
FROM node:22-bookworm-slim AS scraper
WORKDIR /app
ENV NODE_ENV=production
ENV PLAYWRIGHT_BROWSERS_PATH=/app/.playwright
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
COPY --from=builder --chown=nextjs:nodejs /app/scripts ./scripts
COPY --from=builder --chown=nextjs:nodejs /app/lib ./lib
COPY --from=builder --chown=nextjs:nodejs /app/tests ./tests
COPY --from=builder --chown=nextjs:nodejs /app/package.json ./package.json
COPY --from=builder --chown=nextjs:nodejs /app/tsconfig.json ./tsconfig.json
# Full node_modules — includes tsx, playwright, better-sqlite3, all devDeps
COPY --from=deps --chown=nextjs:nodejs /app/node_modules ./node_modules
# Install Playwright Chromium + system libraries (runs as root, then fixes ownership)
RUN npx playwright install --with-deps chromium && \
chown -R nextjs:nodejs /app/.playwright
RUN mkdir -p /app/data && chown nextjs:nodejs /app/data
VOLUME ["/app/data"]
USER nextjs
CMD ["sh", "/app/scripts/scrape-schedule.sh"]

178
README.md
View File

@@ -1,31 +1,48 @@
# Six Flags Super Calendar
# Thoosie Calendar
A week-by-week calendar showing operating hours for all Six Flags Entertainment Group theme parks — including the former Cedar Fair parks. Data is scraped from the Six Flags internal API and stored locally in SQLite.
A week-by-week calendar showing operating hours for all Six Flags Entertainment Group theme parks — including the former Cedar Fair parks. Data is scraped from the Six Flags internal API and stored locally in SQLite. Click any park to see its full month calendar and live ride status with current wait times.
## Parks
24 theme parks across the US, Canada, and Mexico:
24 theme parks across the US, Canada, and Mexico, grouped by region:
**Six Flags branded** — Great Adventure (NJ), Magic Mountain (CA), Great America (IL), Over Georgia, Over Texas, St. Louis, Fiesta Texas (TX), New England (MA), Discovery Kingdom (CA), Mexico, Great Escape (NY), Darien Lake (NY), Frontier City (OK)
**Former Cedar Fair** — Cedar Point (OH), Knott's Berry Farm (CA), Canada's Wonderland (ON), Carowinds (NC), Kings Dominion (VA), Kings Island (OH), Valleyfair (MN), Worlds of Fun (MO), Michigan's Adventure (MI), Dorney Park (PA), California's Great America (CA)
| Region | Parks |
|--------|-------|
| **Northeast** | Great Adventure (NJ), New England (MA), Great Escape (NY), Darien Lake (NY), Dorney Park (PA), Canada's Wonderland (ON) |
| **Southeast** | Over Georgia, Carowinds (NC), Kings Dominion (VA) |
| **Midwest** | Great America (IL), St. Louis (MO), Cedar Point (OH), Kings Island (OH), Valleyfair (MN), Worlds of Fun (MO), Michigan's Adventure (MI) |
| **Texas & South** | Over Texas, Fiesta Texas (TX), Frontier City (OK) |
| **West & International** | Magic Mountain (CA), Discovery Kingdom (CA), Knott's Berry Farm (CA), California's Great America (CA), Mexico |
## Tech Stack
- **Next.js 15** (App Router, Server Components, standalone output)
- **Tailwind CSS v4** (`@theme {}` CSS variables, no config file)
- **Next.js 15** App Router, Server Components, standalone output
- **Tailwind CSS v4** `@theme {}` CSS variables, no config file
- **SQLite** via `better-sqlite3` — persisted in `/app/data/parks.db`
- **Playwright** — one-time headless browser run to discover each park's internal API ID
- **Six Flags CloudFront API** — `https://d18car1k0ff81h.cloudfront.net/operating-hours/park/{id}?date=YYYYMM`
- **Queue-Times.com API** — live ride open/closed status and wait times, updated every 5 minutes
## Ride Status
The park detail page shows ride open/closed status using a two-tier approach:
1. **Live data (Queue-Times.com)** — when a park is operating, ride status and wait times are fetched from the [Queue-Times.com API](https://queue-times.com/en-US/pages/api) and cached for 5 minutes. All 24 parks are mapped. Displays a **Live** badge with per-ride wait times.
2. **Schedule fallback (Six Flags API)** — the Six Flags operating-hours API drops the current day from its response once a park opens. When Queue-Times data is unavailable, the app falls back to the nearest upcoming date from the Six Flags schedule API as an approximation.
### Roller Coaster Filter
When live data is shown, a **Coasters only** toggle appears if roller coaster data has been populated for that park. Coaster lists are sourced from [RCDB](https://rcdb.com) and stored in `data/park-meta.json`. To populate them:
1. Open `data/park-meta.json` and set `rcdb_id` for each park to the numeric RCDB park ID (visible in the URL: `https://rcdb.com/4529.htm``4529`).
2. Run `npm run scrape` — coaster lists are fetched from RCDB and stored in the JSON file. They refresh automatically every 30 days on subsequent scrapes.
---
## Local Development
### Prerequisites
- Node.js 22+
- npm
### Setup
**Prerequisites:** Node.js 22+, npm
```bash
npm install
@@ -40,98 +57,133 @@ Run once to discover each park's internal API ID (opens a headless browser per p
npm run discover
```
Then scrape operating hours for the full year:
Scrape operating hours for the full year:
```bash
npm run scrape
```
To force a full re-scrape (ignores the 7-day staleness window):
Force a full re-scrape (ignores the staleness window):
```bash
npm run scrape:force
```
### Debug a specific park + date
Inspect raw API data and parsed output for any park and date:
```bash
npm run debug -- --park kingsisland --date 2026-06-15
```
Output is printed to the terminal and saved to `debug/{parkId}_{date}.txt`.
### Run tests
```bash
npm test
```
### Run the dev server
```bash
npm run dev
```
Open [http://localhost:3000](http://localhost:3000). Navigate weeks with the `←` / `→` buttons or pass `?week=YYYY-MM-DD` directly.
Open [http://localhost:3000](http://localhost:3000). Navigate weeks with the `←` / `→` buttons, or pass `?week=YYYY-MM-DD` directly. Click any park name to open its detail page.
---
## Deployment
### Docker (standalone)
The app ships as two separate Docker images that share a named volume for the SQLite database:
The app uses Next.js standalone output. The SQLite database is stored in a Docker volume at `/app/data`.
| Image | Tag | Purpose |
|-------|-----|---------|
| Next.js web server | `:web` | Reads DB, serves content. No scraping tools. |
| Scraper + scheduler | `:scraper` | Nightly data refresh. No web server. |
#### Run
Images are built and pushed automatically by CI on every push to `main`.
### First-time setup
**1. Pull the images**
```bash
docker pull gitea.thewrightserver.net/josh/sixflagssupercalendar:web
docker pull gitea.thewrightserver.net/josh/sixflagssupercalendar:scraper
```
**2. Discover park API IDs**
This one-time step opens a headless browser for each park to find its internal Six Flags API ID. Run it against the scraper image so Playwright is available:
```bash
docker run --rm -v root_park_data:/app/data \
gitea.thewrightserver.net/josh/sixflagssupercalendar:scraper \
npm run discover
```
**3. Set RCDB IDs for the coaster filter**
Open `data/park-meta.json` in the Docker volume and set `rcdb_id` for each park to the numeric ID from the RCDB URL (e.g. `https://rcdb.com/4529.htm``4529`). You can curl it directly from the repo:
```bash
curl -o /var/lib/docker/volumes/root_park_data/_data/park-meta.json \
https://gitea.thewrightserver.net/josh/SixFlagsSuperCalendar/raw/branch/main/data/park-meta.json
```
**4. Run the initial scrape**
```bash
docker run --rm -v root_park_data:/app/data \
gitea.thewrightserver.net/josh/sixflagssupercalendar:scraper \
npm run scrape
```
**5. Start services**
```bash
docker compose up -d
```
#### Seed the database inside the container
Both services start. The scraper runs nightly at 3 AM (container timezone, set via `TZ`).
The production image includes Playwright and Chromium, so discovery and scraping can be run directly against the running container's volume.
### Updating
```bash
# Discover API IDs for all parks (one-time, opens headless browser per park)
docker compose exec web npm run discover
# Scrape operating hours for the full year
docker compose exec web npm run scrape
docker compose pull && docker compose up -d
```
Or as one-off containers against the named volume:
### Scraper environment variables
Set these in `docker-compose.yml` under the `scraper` service to override defaults:
| Variable | Default | Description |
|----------|---------|-------------|
| `TZ` | `UTC` | Timezone for the nightly 3 AM run (e.g. `America/New_York`) |
| `PARK_HOURS_STALENESS_HOURS` | `72` | Hours before park schedule data is re-fetched |
| `COASTER_STALENESS_HOURS` | `720` | Hours before RCDB coaster lists are re-fetched (720 = 30 days) |
### Manual scrape
To trigger a scrape outside the nightly schedule:
```bash
docker run --rm -v sixflagssupercalendar_park_data:/app/data \
gitea.thewrightserver.net/josh/sixflagssupercalendar:latest \
npm run discover
docker run --rm -v sixflagssupercalendar_park_data:/app/data \
gitea.thewrightserver.net/josh/sixflagssupercalendar:latest \
npm run scrape
docker compose exec scraper npm run scrape
```
---
### CI/CD (Gitea Actions)
The pipeline is defined at [`.gitea/workflows/deploy.yml`](.gitea/workflows/deploy.yml).
**Trigger:** Push to `main`
**Steps:**
1. Checkout code
2. Log in to the Gitea container registry
3. Build and tag the image as `:latest` and `:<short-sha>`
4. Push both tags
#### Required configuration in Gitea
| Type | Name | Value |
|------|------|-------|
| Variable | `REGISTRY` | Registry hostname — `gitea.thewrightserver.net` |
| Secret | `REGISTRY_TOKEN` | A Gitea access token with `package:write` scope |
Set these under **Repository → Settings → Actions → Variables / Secrets**.
#### Upstream remote
Force re-scrape of all data (ignores staleness):
```bash
git remote add origin https://gitea.thewrightserver.net/josh/SixFlagsSuperCalendar.git
git push -u origin main
docker compose exec scraper npm run scrape:force
```
---
## Data Refresh
The scrape job skips any park+month combination scraped within the last 7 days. To keep data current, run `npm run scrape` (or `scrape:force`) on a schedule — weekly is sufficient for a season calendar.
The scraper skips any park + month already scraped within the staleness window (`PARK_HOURS_STALENESS_HOURS`, default 72h). Past dates are never overwritten — once a day occurs, the API stops returning data for it, so the record written when it was a future date is preserved forever. The nightly scraper handles refresh automatically.
Parks and months not yet in the database show a `—` placeholder in the UI. Parks with no hours data on a given day show "Closed".
Roller coaster lists (from RCDB) are refreshed per `COASTER_STALENESS_HOURS` (default 720h = 30 days) for parks with a configured `rcdb_id`.

View File

@@ -1,49 +1,61 @@
@import "tailwindcss";
@theme {
/* ── Backgrounds ─────────────────────────────────────────────────────────── */
--color-bg: #0c1220;
--color-surface: #141c2e;
--color-surface-2: #1c2640;
--color-surface-hover: #222e4a;
--color-border: #1f2d45;
--color-border-subtle: #172035;
/* ── Backgrounds — deep neutral dark, no purple tint ─────────────────────── */
--color-bg: #111111;
--color-surface: #1c1c1c;
--color-surface-2: #242424;
--color-surface-hover: #2c2c2c;
--color-border: #333333;
--color-border-subtle: #272727;
/* ── Text ────────────────────────────────────────────────────────────────── */
--color-text: #f1f5f9;
--color-text-secondary: #94a3b8;
--color-text-muted: #64748b;
--color-text-dim: #475569;
/* ── Text — clean white, no tint ─────────────────────────────────────────── */
--color-text: #f5f5f5;
--color-text-secondary: #b0b0b0;
--color-text-muted: #737373;
--color-text-dim: #4a4a4a;
/* ── Warm accent (Today / active states) ─────────────────────────────────── */
--color-accent: #f59e0b;
--color-accent-hover: #d97706;
--color-accent-text: #fef3c7;
--color-accent-muted: #78350f;
/* ── Hot pink accent — neon sign energy ──────────────────────────────────── */
--color-accent: #ff4d8d;
--color-accent-hover: #e6006e;
--color-accent-text: #fff0f7;
--color-accent-muted: #3d0f22;
/* ── Open (green) ────────────────────────────────────────────────────────── */
--color-open-bg: #052e16;
--color-open-border: #16a34a;
/* ── Open — electric lime green (go!) ────────────────────────────────────── */
--color-open-bg: #0a1a0d;
--color-open-border: #22c55e;
--color-open-text: #4ade80;
--color-open-hours: #dcfce7;
--color-open-hours: #bbf7d0;
/* ── Passholder preview (purple) ─────────────────────────────────────────── */
--color-ph-bg: #1e0f2e;
--color-ph-border: #7e22ce;
--color-ph-hours: #e9d5ff;
--color-ph-label: #c084fc;
/* ── 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;
/* ── Today column (amber instead of cold blue) ───────────────────────────── */
--color-today-bg: #1c1a0e;
--color-today-border: #f59e0b;
--color-today-text: #fde68a;
/* ── Closing — amber (post-close buffer, rides still winding down) ───────── */
--color-closing-bg: #1a1100;
--color-closing-border: #d97706;
--color-closing-text: #fbbf24;
--color-closing-hours: #fde68a;
/* ── Weekend header ──────────────────────────────────────────────────────── */
--color-weekend-header: #141f35;
/* ── Passholder preview — vivid cyan ─────────────────────────────────────── */
--color-ph-bg: #051518;
--color-ph-border: #22d3ee;
--color-ph-hours: #cffafe;
--color-ph-label: #67e8f9;
/* ── Today — vivid yellow, unmissable ────────────────────────────────────── */
--color-today-bg: #1a1800;
--color-today-border: #facc15;
--color-today-text: #fef08a;
/* ── Weekend — barely-there dark tint ───────────────────────────────────────*/
--color-weekend-header: #181818;
/* ── Region header ───────────────────────────────────────────────────────── */
--color-region-bg: #0e1628;
--color-region-accent: #334155;
--color-region-bg: #161616;
--color-region-accent: #ff4d8d;
}
:root {
@@ -64,14 +76,14 @@
height: 6px;
}
::-webkit-scrollbar-track {
background: var(--color-bg);
background: var(--color-surface-2);
}
::-webkit-scrollbar-thumb {
background: var(--color-border);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-text-muted);
background: var(--color-accent);
}
/* ── Sticky column shadow when scrolling ─────────────────────────────────── */
@@ -80,20 +92,34 @@
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 {
text-decoration: none;
color: inherit;
transition: color 120ms ease;
transition: background 150ms ease;
}
.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;
}
/* ── Park month calendar — responsive row heights ───────────────────────── */
/* Mobile: fixed uniform rows so narrow columns don't cause height variance */
.park-calendar-grid {
grid-auto-rows: 72px;
}
/* sm+: let rows breathe and grow with their content (cells are wide enough) */
@media (min-width: 640px) {
.park-calendar-grid {
grid-auto-rows: minmax(108px, auto);
}
}
/* ── Pulse animation for skeleton ───────────────────────────────────────── */

View File

@@ -2,8 +2,8 @@ import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "Six Flags Calendar",
description: "Theme park operating calendars at a glance",
title: "Thoosie Calendar",
description: "Theme park operating hours and live ride status at a glance",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {

View File

@@ -1,10 +1,12 @@
import { WeekCalendar } from "@/components/WeekCalendar";
import { MobileCardList } from "@/components/MobileCardList";
import { WeekNav } from "@/components/WeekNav";
import { Legend } from "@/components/Legend";
import { EmptyState } from "@/components/EmptyState";
import { PARKS, groupByRegion } from "@/lib/parks";
import { openDb, getDateRange } from "@/lib/db";
import { HomePageClient } from "@/components/HomePageClient";
import { PARKS } from "@/lib/parks";
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 }>;
@@ -18,9 +20,10 @@ function getWeekStart(param: string | undefined): string {
return d.toISOString().slice(0, 10);
}
}
const today = new Date();
today.setDate(today.getDate() - today.getDay());
return today.toISOString().slice(0, 10);
const todayIso = getTodayLocal();
const d = new Date(todayIso + "T00:00:00");
d.setDate(d.getDate() - d.getDay());
return d.toISOString().slice(0, 10);
}
function getWeekDates(sundayIso: string): string[] {
@@ -32,9 +35,10 @@ function getWeekDates(sundayIso: string): string[] {
}
function getCurrentWeekStart(): string {
const today = new Date();
today.setDate(today.getDate() - today.getDay());
return today.toISOString().slice(0, 10);
const todayIso = getTodayLocal();
const d = new Date(todayIso + "T00:00:00");
d.setDate(d.getDate() - d.getDay());
return d.toISOString().slice(0, 10);
}
export default async function HomePage({ searchParams }: PageProps) {
@@ -42,11 +46,36 @@ export default async function HomePage({ searchParams }: PageProps) {
const weekStart = getWeekStart(params.week);
const weekDates = getWeekDates(weekStart);
const endDate = weekDates[6];
const today = new Date().toISOString().slice(0, 10);
const today = getTodayLocal();
const isCurrentWeek = weekStart === getCurrentWeekStart();
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(
@@ -54,98 +83,68 @@ export default async function HomePage({ searchParams }: PageProps) {
0
);
const visibleParks = PARKS.filter((park) =>
weekDates.some((date) => data[park.id]?.[date]?.isOpen)
);
// Always fetch both ride and coaster counts — the client decides which to display.
const parkMeta = readParkMeta();
const hasCoasterData = PARKS.some((p) => (parkMeta[p.id]?.coasters.length ?? 0) > 0);
const grouped = groupByRegion(visibleParks);
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 || !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];
return dayData?.hoursLabel
? getOperatingStatus(dayData.hoursLabel, p.timezone) === "closing"
: 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(
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 : 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 != null && rideCount > 0).map(({ id, rideCount }) => [id, rideCount!])
);
coasterCounts = Object.fromEntries(
results.filter(({ coasterCount }) => coasterCount > 0).map(({ id, coasterCount }) => [id, coasterCount])
);
}
return (
<div style={{ minHeight: "100vh", background: "var(--color-bg)" }}>
{/* ── Header ─────────────────────────────────────────────────────────── */}
<header style={{
position: "sticky",
top: 0,
zIndex: 20,
background: "var(--color-bg)",
borderBottom: "1px solid var(--color-border)",
}}>
{/* Row 1: Title + park count */}
<div style={{
padding: "12px 24px 10px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
}}>
<span style={{
fontSize: "1.1rem",
fontWeight: 700,
color: "var(--color-text)",
letterSpacing: "-0.02em",
}}>
Six Flags Calendar
</span>
<span style={{
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
borderRadius: 20,
padding: "3px 10px",
fontSize: "0.7rem",
color: "var(--color-text-muted)",
fontWeight: 500,
}}>
{visibleParks.length} of {PARKS.length} parks open
</span>
</div>
{/* Row 2: Week nav + legend */}
<div style={{
padding: "8px 24px 10px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 16,
borderTop: "1px solid var(--color-border-subtle)",
}}>
<WeekNav
weekStart={weekStart}
weekDates={weekDates}
isCurrentWeek={isCurrentWeek}
/>
<Legend />
</div>
</header>
{/* ── Main content ───────────────────────────────────────────────────── */}
<main style={{ padding: "0 24px 48px" }}>
{scrapedCount === 0 ? (
<EmptyState />
) : (
<>
{/* Mobile: card list (hidden on lg+) */}
<div className="lg:hidden">
<MobileCardList
grouped={grouped}
weekDates={weekDates}
data={data}
today={today}
/>
</div>
{/* Desktop: week table (hidden below lg) */}
<div className="hidden lg:block">
<WeekCalendar
parks={visibleParks}
weekDates={weekDates}
data={data}
grouped={grouped}
/>
</div>
</>
)}
</main>
</div>
<HomePageClient
weekStart={weekStart}
weekDates={weekDates}
today={today}
isCurrentWeek={isCurrentWeek}
data={data}
rideCounts={rideCounts}
coasterCounts={coasterCounts}
openParkIds={openParkIds}
closingParkIds={closingParkIds}
weatherDelayParkIds={weatherDelayParkIds}
hasCoasterData={hasCoasterData}
scrapedCount={scrapedCount}
/>
);
}

View File

@@ -1,10 +1,18 @@
import Link from "next/link";
import { BackToCalendarLink } from "@/components/BackToCalendarLink";
import { notFound } from "next/navigation";
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";
import { LiveRidePanel } from "@/components/LiveRidePanel";
import type { RideStatus, RidesFetchResult } from "@/lib/scrapers/sixflags";
import type { LiveRidesResult } from "@/lib/scrapers/queuetimes"; // used as prop type below
import { getTodayLocal, isWithinOperatingWindow } from "@/lib/env";
interface PageProps {
params: Promise<{ id: string }>;
@@ -18,8 +26,8 @@ function parseMonthParam(param: string | undefined): { year: number; month: numb
return { year: y, month: m };
}
}
const now = new Date();
return { year: now.getFullYear(), month: now.getMonth() + 1 };
const [y, m] = getTodayLocal().split("-").map(Number);
return { year: y, month: m };
}
export default async function ParkPage({ params, searchParams }: PageProps) {
@@ -29,7 +37,7 @@ export default async function ParkPage({ params, searchParams }: PageProps) {
const park = PARK_MAP.get(id);
if (!park) notFound();
const today = new Date().toISOString().slice(0, 10);
const today = getTodayLocal();
const { year, month } = parseMonthParam(monthParam);
const db = openDb();
@@ -37,16 +45,52 @@ export default async function ParkPage({ params, searchParams }: PageProps) {
const apiId = getApiId(db, id);
db.close();
// Fetch live ride data — cached 1h via Next.js ISR.
// Note: the API drops today's date from its response (only returns future dates),
// so scrapeRidesForDay may fall back to the nearest upcoming date.
// 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 ──────────
const queueTimesId = QUEUE_TIMES_IDS[id];
const parkMeta = readParkMeta();
const coasterSet = getCoasterSet(id, parkMeta);
let liveRides: LiveRidesResult | null = null;
let ridesResult: RidesFetchResult | null = null;
if (apiId !== null) {
ridesResult = await scrapeRidesForDay(apiId, today);
// Determine if we're within the 1h-before-open to 1h-after-close window.
const withinWindow = todayData?.hoursLabel
? isWithinOperatingWindow(todayData.hoursLabel, park.timezone)
: false;
if (queueTimesId) {
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 })),
};
}
}
const todayData = monthData[today];
const parkOpenToday = todayData?.isOpen && todayData?.hoursLabel;
// 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) {
ridesResult = await scrapeRidesForDay(apiId, today);
}
return (
<div style={{ minHeight: "100vh", background: "var(--color-bg)" }}>
@@ -62,19 +106,7 @@ export default async function ParkPage({ params, searchParams }: PageProps) {
alignItems: "center",
gap: 16,
}}>
<Link href="/" style={{
display: "flex",
alignItems: "center",
gap: 6,
fontSize: "0.8rem",
color: "var(--color-text-muted)",
textDecoration: "none",
transition: "color 120ms ease",
}}
className="park-name-link"
>
Calendar
</Link>
<BackToCalendarLink />
<div style={{ width: 1, height: 16, background: "var(--color-border)" }} />
<span style={{ fontSize: "0.9rem", fontWeight: 600, color: "var(--color-text)", letterSpacing: "-0.01em" }}>
{park.name}
@@ -84,7 +116,7 @@ export default async function ParkPage({ params, searchParams }: PageProps) {
</span>
</header>
<main style={{ padding: "24px", maxWidth: 960, margin: "0 auto", display: "flex", flexDirection: "column", gap: 40 }}>
<main style={{ padding: "24px 32px", maxWidth: 1280, margin: "0 auto", display: "flex", flexDirection: "column", gap: 40 }}>
{/* ── Month Calendar ───────────────────────────────────────────────── */}
<section>
@@ -94,25 +126,58 @@ export default async function ParkPage({ params, searchParams }: PageProps) {
month={month}
monthData={monthData}
today={today}
timezone={park.timezone}
/>
</section>
{/* ── Ride Status ─────────────────────────────────────────────────── */}
<section>
<SectionHeading>
<SectionHeading aside={liveRides ? (
<a
href="https://queue-times.com"
target="_blank"
rel="noopener noreferrer"
style={{
fontSize: "0.68rem",
color: "var(--color-text-dim)",
textDecoration: "none",
display: "flex",
alignItems: "center",
gap: 4,
transition: "color 120ms ease",
}}
className="park-name-link"
>
via queue-times.com
</a>
) : undefined}>
Rides
<span style={{ fontSize: "0.72rem", fontWeight: 400, color: "var(--color-text-muted)", marginLeft: 8 }}>
{ridesResult && !ridesResult.isExact
? formatShortDate(ridesResult.dataDate)
: "Today"}
</span>
{liveRides ? (
<LiveBadge />
) : ridesResult && !ridesResult.isExact ? (
<span style={{ fontSize: "0.72rem", fontWeight: 400, color: "var(--color-text-muted)", marginLeft: 8 }}>
{formatShortDate(ridesResult.dataDate)}
</span>
) : (
<span style={{ fontSize: "0.72rem", fontWeight: 400, color: "var(--color-text-muted)", marginLeft: 8 }}>
Today
</span>
)}
</SectionHeading>
<RideList
ridesResult={ridesResult}
parkOpenToday={!!parkOpenToday}
apiIdMissing={apiId === null}
/>
{liveRides ? (
<LiveRidePanel
liveRides={liveRides}
parkOpenToday={!!parkOpenToday}
isWeatherDelay={isWeatherDelay}
/>
) : (
<RideList
ridesResult={ridesResult}
parkOpenToday={!!parkOpenToday}
apiIdMissing={apiId === null && !queueTimesId}
/>
)}
</section>
</main>
</div>
@@ -129,12 +194,12 @@ function formatShortDate(iso: string): string {
// ── Sub-components ─────────────────────────────────────────────────────────
function SectionHeading({ children }: { children: React.ReactNode }) {
function SectionHeading({ children, aside }: { children: React.ReactNode; aside?: React.ReactNode }) {
return (
<div style={{
display: "flex",
alignItems: "baseline",
gap: 0,
alignItems: "center",
justifyContent: "space-between",
marginBottom: 14,
paddingBottom: 10,
borderBottom: "1px solid var(--color-border)",
@@ -146,13 +211,48 @@ function SectionHeading({ children }: { children: React.ReactNode }) {
letterSpacing: "0.04em",
textTransform: "uppercase",
margin: 0,
display: "flex",
alignItems: "center",
}}>
{children}
</h2>
{aside}
</div>
);
}
function LiveBadge() {
return (
<span style={{
display: "inline-flex",
alignItems: "center",
gap: 5,
marginLeft: 10,
padding: "2px 8px",
borderRadius: 20,
background: "var(--color-open-bg)",
border: "1px solid var(--color-open-border)",
fontSize: "0.65rem",
fontWeight: 700,
letterSpacing: "0.06em",
textTransform: "uppercase",
color: "var(--color-open-text)",
verticalAlign: "middle",
}}>
<span style={{
width: 5,
height: 5,
borderRadius: "50%",
background: "var(--color-open-text)",
display: "inline-block",
}} />
Live
</span>
);
}
// ── Schedule ride list (Six Flags operating-hours API fallback) ────────────
function RideList({
ridesResult,
parkOpenToday,
@@ -235,9 +335,6 @@ function RideList({
}
function RideRow({ ride, parkHoursLabel }: { ride: RideStatus; parkHoursLabel?: string }) {
// Only show the ride's hours when they differ from the park's overall hours.
// This avoids repeating "10am 6pm" on every single row when that's the
// default — but surfaces exceptions like "11am 4pm" for Safari tours, etc.
const showHours = ride.isOpen && ride.hoursLabel && ride.hoursLabel !== parkHoursLabel;
return (
@@ -260,7 +357,7 @@ function RideRow({ ride, parkHoursLabel }: { ride: RideStatus; parkHoursLabel?:
background: ride.isOpen ? "var(--color-open-text)" : "var(--color-text-dim)",
flexShrink: 0,
}} />
<span style={{
<span title={ride.name} style={{
fontSize: "0.8rem",
color: ride.isOpen ? "var(--color-text)" : "var(--color-text-muted)",
fontWeight: ride.isOpen ? 500 : 400,

View File

@@ -0,0 +1,31 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
export function BackToCalendarLink() {
const [href, setHref] = useState("/");
useEffect(() => {
const saved = localStorage.getItem("lastWeek");
if (saved) setHref(`/?week=${saved}`);
}, []);
return (
<Link
href={href}
className="park-name-link"
style={{
display: "flex",
alignItems: "center",
gap: 6,
fontSize: "0.8rem",
color: "var(--color-text-muted)",
textDecoration: "none",
transition: "color 120ms ease",
}}
>
Calendar
</Link>
);
}

View File

@@ -10,21 +10,11 @@ export function EmptyState() {
color: "var(--color-text-muted)",
}}>
<div style={{ fontSize: "2rem" }}>📅</div>
<div style={{ fontSize: "1rem", fontWeight: 600, color: "var(--color-text)" }}>No data scraped yet</div>
<div style={{ fontSize: "1rem", fontWeight: 600, color: "var(--color-text)" }}>Schedule not available yet</div>
<div style={{ fontSize: "0.85rem", textAlign: "center", lineHeight: 1.6 }}>
Run the following to populate the calendar:
Park hours for this period haven&apos;t been published yet.<br />
Check back closer to your visit.
</div>
<pre style={{
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
borderRadius: 8,
padding: "12px 20px",
fontSize: "0.8rem",
color: "var(--color-text)",
lineHeight: 1.8,
}}>
npm run discover{"\n"}npm run scrape
</pre>
</div>
);
}

View File

@@ -0,0 +1,263 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { WeekCalendar } from "./WeekCalendar";
import { MobileCardList } from "./MobileCardList";
import { WeekNav } from "./WeekNav";
import { Legend } from "./Legend";
import { EmptyState } from "./EmptyState";
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";
interface HomePageClientProps {
weekStart: string;
weekDates: string[];
today: string;
isCurrentWeek: boolean;
data: Record<string, Record<string, DayData>>;
rideCounts: Record<string, number>;
coasterCounts: Record<string, number>;
openParkIds: string[];
closingParkIds: string[];
weatherDelayParkIds: string[];
hasCoasterData: boolean;
scrapedCount: number;
}
export function HomePageClient({
weekStart,
weekDates,
today,
isCurrentWeek,
data,
rideCounts,
coasterCounts,
openParkIds,
closingParkIds,
weatherDelayParkIds,
hasCoasterData,
scrapedCount,
}: HomePageClientProps) {
const router = useRouter();
const [coastersOnly, setCoastersOnly] = useState(false);
// Hydrate from localStorage after mount to avoid SSR mismatch.
useEffect(() => {
setCoastersOnly(localStorage.getItem(COASTER_MODE_KEY) === "true");
}, []);
// Periodically re-fetch server data (ride counts, open status) without a full page reload.
useEffect(() => {
if (!isCurrentWeek) return;
const id = setInterval(() => router.refresh(), REFRESH_INTERVAL_MS);
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);
}, [weekStart]);
const toggle = () => {
const next = !coastersOnly;
setCoastersOnly(next);
localStorage.setItem(COASTER_MODE_KEY, String(next));
};
const activeCounts = coastersOnly ? coasterCounts : rideCounts;
const visibleParks = PARKS.filter((park) =>
weekDates.some((date) => data[park.id]?.[date]?.isOpen)
);
const grouped = groupByRegion(visibleParks);
return (
<div style={{ minHeight: "100vh", background: "var(--color-bg)" }}>
{/* ── Header ───────────────────────────────────────────────────────────── */}
<header style={{
position: "sticky",
top: 0,
zIndex: 20,
background: "var(--color-bg)",
borderBottom: "1px solid var(--color-border)",
}}>
{/* Row 1: Title + controls */}
<div style={{
padding: "12px 16px 10px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
}}>
<span style={{
fontSize: "1.1rem",
fontWeight: 700,
color: "var(--color-text)",
letterSpacing: "-0.02em",
}}>
Thoosie Calendar
</span>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
{hasCoasterData && (
<button
onClick={toggle}
style={{
display: "flex",
alignItems: "center",
gap: 5,
padding: "4px 12px",
borderRadius: 20,
border: coastersOnly
? "1px solid var(--color-accent)"
: "1px solid var(--color-border)",
background: coastersOnly
? "var(--color-accent-muted)"
: "var(--color-surface)",
color: coastersOnly
? "var(--color-accent)"
: "var(--color-text-muted)",
fontSize: "0.72rem",
fontWeight: 600,
cursor: "pointer",
transition: "background 150ms ease, border-color 150ms ease, color 150ms ease",
whiteSpace: "nowrap",
}}
>
🎢 Coaster Mode
</button>
)}
<span className="hidden sm:inline-flex" style={{
background: visibleParks.length > 0 ? "var(--color-open-bg)" : "var(--color-surface)",
border: `1px solid ${visibleParks.length > 0 ? "var(--color-open-border)" : "var(--color-border)"}`,
borderRadius: 20,
padding: "4px 14px",
fontSize: "0.78rem",
color: visibleParks.length > 0 ? "var(--color-open-hours)" : "var(--color-text-muted)",
fontWeight: 600,
alignItems: "center",
whiteSpace: "nowrap",
}}>
{visibleParks.length} of {PARKS.length} parks open this week
</span>
</div>
</div>
{/* Row 2: Week nav + legend */}
<div style={{
padding: "8px 16px 10px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 16,
borderTop: "1px solid var(--color-border-subtle)",
}}>
<WeekNav
weekStart={weekStart}
weekDates={weekDates}
isCurrentWeek={isCurrentWeek}
/>
<div className="hidden sm:flex">
<Legend />
</div>
</div>
</header>
{/* ── Main content ────────────────────────────────────────────────────── */}
<main className="px-4 sm:px-6 pb-12">
{scrapedCount === 0 ? (
<EmptyState />
) : (
<>
{/* Mobile: card list (hidden on lg+) */}
<div className="lg:hidden">
<MobileCardList
grouped={grouped}
weekDates={weekDates}
data={data}
today={today}
rideCounts={activeCounts}
coastersOnly={coastersOnly}
openParkIds={openParkIds}
closingParkIds={closingParkIds}
weatherDelayParkIds={weatherDelayParkIds}
/>
</div>
{/* Desktop: week table (hidden below lg) */}
<div className="hidden lg:block">
<WeekCalendar
parks={visibleParks}
weekDates={weekDates}
data={data}
grouped={grouped}
rideCounts={activeCounts}
coastersOnly={coastersOnly}
openParkIds={openParkIds}
closingParkIds={closingParkIds}
weatherDelayParkIds={weatherDelayParkIds}
/>
</div>
</>
)}
</main>
</div>
);
}

View File

@@ -0,0 +1,201 @@
"use client";
import { useState, useEffect } from "react";
import type { LiveRidesResult, LiveRide } from "@/lib/scrapers/queuetimes";
interface LiveRidePanelProps {
liveRides: LiveRidesResult;
parkOpenToday: boolean;
isWeatherDelay?: boolean;
}
export function LiveRidePanel({ liveRides, parkOpenToday, isWeatherDelay }: LiveRidePanelProps) {
const { rides } = liveRides;
const hasCoasters = rides.some((r) => r.isCoaster);
const [coastersOnly, setCoastersOnly] = useState(false);
// Pre-select coaster filter if Coaster Mode is enabled on the homepage.
useEffect(() => {
if (hasCoasters && localStorage.getItem("coasterMode") === "true") {
setCoastersOnly(true);
}
}, [hasCoasters]);
const visible = coastersOnly ? rides.filter((r) => r.isCoaster) : rides;
const openRides = visible.filter((r) => r.isOpen);
const closedRides = visible.filter((r) => !r.isOpen);
const anyOpen = openRides.length > 0;
return (
<div>
{/* ── Toolbar: summary + coaster toggle ────────────────────────────── */}
<div style={{
display: "flex",
alignItems: "center",
gap: 10,
marginBottom: 16,
flexWrap: "wrap",
}}>
{/* Open count badge */}
{anyOpen ? (
<div style={{
background: "var(--color-open-bg)",
border: "1px solid var(--color-open-border)",
borderRadius: 20,
padding: "4px 12px",
fontSize: "0.72rem",
fontWeight: 600,
color: "var(--color-open-hours)",
flexShrink: 0,
}}>
{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)",
border: "1px solid var(--color-border)",
borderRadius: 20,
padding: "4px 12px",
fontSize: "0.72rem",
fontWeight: 500,
color: "var(--color-text-muted)",
flexShrink: 0,
}}>
{parkOpenToday ? "Not open yet — check back soon" : "No rides open"}
</div>
)}
{/* Closed count badge — always shown when there are closed rides */}
{closedRides.length > 0 && (
<div style={{
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
borderRadius: 20,
padding: "4px 12px",
fontSize: "0.72rem",
fontWeight: 500,
color: "var(--color-text-muted)",
flexShrink: 0,
}}>
{closedRides.length} {anyOpen ? "closed / down" : "rides total"}
</div>
)}
{/* Coaster toggle — only shown when the park has categorised coasters */}
{hasCoasters && (
<button
onClick={() => setCoastersOnly((v) => !v)}
style={{
marginLeft: "auto",
display: "flex",
alignItems: "center",
gap: 5,
padding: "4px 12px",
borderRadius: 20,
border: coastersOnly
? "1px solid var(--color-accent)"
: "1px solid var(--color-border)",
background: coastersOnly
? "var(--color-accent-muted)"
: "var(--color-surface)",
color: coastersOnly
? "var(--color-accent)"
: "var(--color-text-muted)",
fontSize: "0.72rem",
fontWeight: 600,
cursor: "pointer",
transition: "background 150ms ease, border-color 150ms ease, color 150ms ease",
whiteSpace: "nowrap",
}}
>
🎢 Coasters only
</button>
)}
</div>
{/* ── Ride grid ────────────────────────────────────────────────────── */}
<div style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))",
gap: 6,
}}>
{openRides.map((ride) => <RideRow key={ride.name} ride={ride} />)}
{closedRides.map((ride) => <RideRow key={ride.name} ride={ride} />)}
</div>
</div>
);
}
function RideRow({ ride }: { ride: LiveRide }) {
const showWait = ride.isOpen && ride.waitMinutes > 0;
return (
<div style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 10,
padding: "8px 12px",
background: "var(--color-surface)",
border: `1px solid ${ride.isOpen ? "var(--color-open-border)" : "var(--color-border)"}`,
borderRadius: 8,
opacity: ride.isOpen ? 1 : 0.6,
}}>
<div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}>
<span style={{
width: 7,
height: 7,
borderRadius: "50%",
background: ride.isOpen ? "var(--color-open-text)" : "var(--color-text-dim)",
flexShrink: 0,
}} />
<span title={ride.name} style={{
fontSize: "0.8rem",
color: ride.isOpen ? "var(--color-text)" : "var(--color-text-muted)",
fontWeight: ride.isOpen ? 500 : 400,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}>
{ride.name}
</span>
</div>
{showWait && (
<span style={{
fontSize: "0.72rem",
color: "var(--color-open-hours)",
fontWeight: 600,
flexShrink: 0,
whiteSpace: "nowrap",
}}>
{ride.waitMinutes} min
</span>
)}
{ride.isOpen && !showWait && (
<span style={{
fontSize: "0.68rem",
color: "var(--color-open-text)",
fontWeight: 500,
flexShrink: 0,
opacity: 0.7,
}}>
walk-on
</span>
)}
</div>
);
}

View File

@@ -8,36 +8,33 @@ interface MobileCardListProps {
weekDates: string[];
data: Record<string, Record<string, DayData>>;
today: string;
rideCounts?: Record<string, number>;
coastersOnly?: boolean;
openParkIds?: string[];
closingParkIds?: string[];
weatherDelayParkIds?: string[];
}
export function MobileCardList({ grouped, weekDates, data, today }: MobileCardListProps) {
export function MobileCardList({ grouped, weekDates, data, today, rideCounts, coastersOnly, openParkIds, closingParkIds, weatherDelayParkIds }: MobileCardListProps) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 24, paddingTop: 16 }}>
<div style={{ display: "flex", flexDirection: "column", gap: 20, paddingTop: 14 }}>
{Array.from(grouped.entries()).map(([region, parks]) => (
<div key={region} data-region={region}>
{/* Region heading */}
<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>
@@ -50,7 +47,12 @@ export function MobileCardList({ grouped, weekDates, data, today }: MobileCardLi
weekDates={weekDates}
parkData={data[park.id] ?? {}}
today={today}
/>
openRideCount={rideCounts?.[park.id]}
coastersOnly={coastersOnly}
isOpen={openParkIds?.includes(park.id)}
isClosing={closingParkIds?.includes(park.id)}
isWeatherDelay={weatherDelayParkIds?.includes(park.id)}
/>
))}
</div>
</div>

View File

@@ -1,170 +1,188 @@
import Link from "next/link";
import type { Park } from "@/lib/scrapers/types";
import type { DayData } from "@/lib/db";
import { getTimezoneAbbr } from "@/lib/env";
interface ParkCardProps {
park: Park;
weekDates: string[]; // 7 dates YYYY-MM-DD
weekDates: string[]; // 7 dates YYYY-MM-DD, SunSat
parkData: Record<string, DayData>;
today: string;
openRideCount?: number;
coastersOnly?: boolean;
isOpen?: boolean;
isClosing?: boolean;
isWeatherDelay?: boolean;
}
const DOW_SHORT = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
const DOW = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
function parseDate(iso: string) {
const d = new Date(iso + "T00:00:00");
return { day: d.getDate(), dow: d.getDay(), isWeekend: d.getDay() === 0 || d.getDay() === 6 };
}
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);
export function ParkCard({ park, weekDates, parkData, today }: ParkCardProps) {
return (
<div
<Link
href={`/park/${park.id}`}
data-park={park.name.toLowerCase()}
style={{
style={{ textDecoration: "none", display: "block" }}
>
<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,
padding: "14px 14px 12px",
display: "flex",
flexDirection: "column",
gap: 10,
}}
>
{/* Park name + location */}
<div>
<Link href={`/park/${park.id}`} className="park-name-link">
<span style={{ fontWeight: 600, fontSize: "0.9rem", lineHeight: 1.2 }}>
{park.name}
</span>
</Link>
<div style={{ fontSize: "0.7rem", color: "var(--color-text-muted)", marginTop: 2 }}>
{park.location.city}, {park.location.state}
</div>
</div>
{/* 7-day grid */}
<div style={{
display: "grid",
gridTemplateColumns: "repeat(7, 1fr)",
gap: 4,
overflow: "hidden",
}}>
{weekDates.map((date) => {
const pd = parseDate(date);
const isToday = date === today;
const dayData = parkData[date];
const isOpen = dayData?.isOpen && dayData?.hoursLabel;
const isPH = dayData?.specialType === "passholder_preview";
let cellBg = "transparent";
let cellBorder = "1px solid var(--color-border-subtle)";
const cellBorderRadius = "6px";
if (isToday) {
cellBg = "var(--color-today-bg)";
cellBorder = `1px solid var(--color-today-border)`;
} else if (pd.isWeekend) {
cellBg = "var(--color-weekend-header)";
}
return (
<div key={date} style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 3,
padding: "6px 2px",
background: cellBg,
border: cellBorder,
borderRadius: cellBorderRadius,
minWidth: 0,
}}>
{/* Day name */}
<span style={{
fontSize: "0.6rem",
textTransform: "uppercase",
letterSpacing: "0.04em",
color: isToday ? "var(--color-today-text)" : pd.isWeekend ? "var(--color-text-secondary)" : "var(--color-text-muted)",
fontWeight: isToday || pd.isWeekend ? 600 : 400,
}}>
{DOW_SHORT[pd.dow]}
</span>
{/* Date number */}
<span style={{
fontSize: "0.8rem",
fontWeight: isToday ? 700 : 500,
color: isToday ? "var(--color-today-text)" : pd.isWeekend ? "var(--color-text)" : "var(--color-text-secondary)",
lineHeight: 1,
}}>
{pd.day}
</span>
{/* Status */}
{!dayData ? (
<span style={{ fontSize: "0.65rem", color: "var(--color-text-dim)", lineHeight: 1 }}></span>
) : isPH && isOpen ? (
<span style={{
fontSize: "0.6rem",
fontWeight: 700,
color: "var(--color-ph-label)",
letterSpacing: "0.02em",
textAlign: "center",
lineHeight: 1.2,
}}>
PH
</span>
) : isOpen ? (
<span style={{
fontSize: "0.58rem",
fontWeight: 600,
color: "var(--color-open-text)",
lineHeight: 1,
textAlign: "center",
}}>
Open
</span>
) : (
<span style={{ fontSize: "0.9rem", color: "var(--color-text-dim)", lineHeight: 1 }}>·</span>
)}
</div>
);
})}
</div>
{/* Hours detail row — show the open day hours inline */}
{weekDates.some((d) => parkData[d]?.isOpen && parkData[d]?.hoursLabel) && (
{/* ── Card header ───────────────────────────────────────────────────── */}
<div style={{
padding: "14px 16px 12px",
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
flexWrap: "wrap",
gap: "4px 12px",
paddingTop: 4,
borderTop: "1px solid var(--color-border-subtle)",
gap: 12,
}}>
{weekDates.map((date) => {
const pd = parseDate(date);
const dayData = parkData[date];
if (!dayData?.isOpen || !dayData?.hoursLabel) return null;
const isPH = dayData.specialType === "passholder_preview";
return (
<span key={date} style={{
fontSize: "0.68rem",
color: isPH ? "var(--color-ph-hours)" : "var(--color-open-hours)",
display: "flex",
gap: 4,
alignItems: "center",
}}>
<span style={{ color: "var(--color-text-muted)", fontWeight: 600 }}>
{DOW_SHORT[pd.dow]}
</span>
{dayData.hoursLabel}
{isPH && (
<span style={{ color: "var(--color-ph-label)", fontSize: "0.6rem", fontWeight: 700 }}>PH</span>
)}
</span>
);
})}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: "0.95rem",
fontWeight: 600,
color: "var(--color-text)",
lineHeight: 1.2,
}}>
{park.name}
</div>
<div style={{
fontSize: "0.72rem",
color: "var(--color-text-muted)",
marginTop: 3,
}}>
{park.location.city}, {park.location.state}
</div>
</div>
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 5 }}>
{isOpenToday ? (
<div style={{
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: isWeatherDelay ? "var(--color-weather-text)" : isClosing ? "var(--color-closing-text)" : "var(--color-open-text)",
whiteSpace: "nowrap",
letterSpacing: "0.03em",
}}>
{isWeatherDelay ? "⛈ Weather Delay" : isClosing ? "Closing" : "Open today"}
</div>
) : (
<div style={{
background: "transparent",
border: "1px solid var(--color-border)",
borderRadius: 20,
padding: "4px 10px",
fontSize: "0.65rem",
fontWeight: 500,
color: "var(--color-text-muted)",
whiteSpace: "nowrap",
}}>
Closed today
</div>
)}
{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)",
fontWeight: 500,
textAlign: "right",
}}>
{openRideCount} {coastersOnly
? (openRideCount === 1 ? "coaster" : "coasters")
: (openRideCount === 1 ? "ride" : "rides")} operating
</div>
)}
</div>
</div>
)}
</div>
{/* ── Open days list ────────────────────────────────────────────────── */}
{openDays.length > 0 && (
<div style={{ borderTop: "1px solid var(--color-border-subtle)" }}>
{openDays.map((date, i) => {
const dow = new Date(date + "T00:00:00").getDay();
const isToday = date === today;
const dayData = parkData[date];
const isPH = dayData.specialType === "passholder_preview";
const isLast = i === openDays.length - 1;
return (
<div
key={date}
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "9px 16px",
background: isToday ? "var(--color-today-bg)" : "transparent",
borderBottom: isLast ? "none" : "1px solid var(--color-border-subtle)",
}}
>
<span style={{
fontSize: "0.82rem",
fontWeight: isToday ? 600 : 400,
color: isToday
? "var(--color-today-text)"
: "var(--color-text-secondary)",
}}>
{isToday ? "Today" : DOW[dow]}
</span>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
{isPH && (
<span style={{
fontSize: "0.65rem",
fontWeight: 700,
color: "var(--color-ph-label)",
letterSpacing: "0.04em",
textTransform: "uppercase",
}}>
Passholder
</span>
)}
<span style={{
fontSize: "0.82rem",
fontWeight: isToday ? 600 : 500,
color: isPH
? "var(--color-ph-hours)"
: isToday
? "var(--color-today-text)"
: "var(--color-open-hours)",
}}>
{dayData.hoursLabel}{" "}
<span style={{ fontSize: "0.68rem", fontWeight: 400, color: "var(--color-text-dim)" }}>
{tzAbbr}
</span>
</span>
</div>
</div>
);
})}
</div>
)}
</div>
</Link>
);
}

View File

@@ -1,5 +1,6 @@
import Link from "next/link";
import type { DayData } from "@/lib/db";
import { getTimezoneAbbr } from "@/lib/env";
interface ParkMonthCalendarProps {
parkId: string;
@@ -7,6 +8,7 @@ interface ParkMonthCalendarProps {
month: number; // 1-indexed
monthData: Record<string, DayData>; // 'YYYY-MM-DD' → DayData
today: string; // YYYY-MM-DD
timezone: string; // IANA timezone, e.g. "America/New_York"
}
const DOW_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
@@ -29,7 +31,8 @@ function daysInMonth(year: number, month: number): number {
return new Date(year, month, 0).getDate();
}
export function ParkMonthCalendar({ parkId, year, month, monthData, today }: ParkMonthCalendarProps) {
export function ParkMonthCalendar({ parkId, year, month, monthData, today, timezone }: ParkMonthCalendarProps) {
const tzAbbr = getTimezoneAbbr(timezone);
const firstDow = new Date(year, month - 1, 1).getDay(); // 0=Sun
const totalDays = daysInMonth(year, month);
@@ -115,97 +118,126 @@ export function ParkMonthCalendar({ parkId, year, month, monthData, today }: Par
))}
</div>
{/* Weeks */}
{weeks.map((week, wi) => (
<div key={wi} style={{
display: "grid",
gridTemplateColumns: "repeat(7, 1fr)",
borderBottom: wi < weeks.length - 1 ? "1px solid var(--color-border-subtle)" : "none",
}}>
{week.map((cell, ci) => {
if (!cell.day || !cell.iso) {
return (
<div key={ci} style={{
minHeight: 72,
background: ci === 0 || ci === 6 ? "var(--color-weekend-header)" : "transparent",
borderRight: ci < 6 ? "1px solid var(--color-border-subtle)" : "none",
}} />
);
}
const dayData = monthData[cell.iso];
const isToday = cell.iso === today;
const isWeekend = ci === 0 || ci === 6;
const isOpen = dayData?.isOpen && dayData?.hoursLabel;
const isPH = dayData?.specialType === "passholder_preview";
const bg = isToday
? "var(--color-today-bg)"
: isWeekend
? "var(--color-weekend-header)"
: "transparent";
{/*
All day cells in ONE flat grid — eliminates per-week wrapper
divs that caused independent row heights and the slant effect.
Row height is controlled responsively via .park-calendar-grid CSS:
mobile = 72px fixed, sm+ = minmax(96px, auto).
*/}
<div
className="park-calendar-grid"
style={{ display: "grid", gridTemplateColumns: "repeat(7, 1fr)" }}
>
{cells.map((cell, idx) => {
const ci = idx % 7;
const isLastRow = idx >= cells.length - 7;
const borderBottom = !isLastRow ? "1px solid var(--color-border-subtle)" : "none";
const borderRight = ci < 6 ? "1px solid var(--color-border-subtle)" : "none";
if (!cell.day || !cell.iso) {
return (
<div key={ci} style={{
minHeight: 72,
padding: "8px 10px",
background: bg,
borderRight: ci < 6 ? "1px solid var(--color-border-subtle)" : "none",
borderLeft: isToday ? "2px solid var(--color-today-border)" : "none",
display: "flex",
flexDirection: "column",
gap: 4,
}}>
{/* Date number */}
<span style={{
fontSize: "0.85rem",
fontWeight: isToday ? 700 : isWeekend ? 600 : 400,
color: isToday
? "var(--color-today-text)"
: isWeekend
? "var(--color-text)"
: "var(--color-text-muted)",
lineHeight: 1,
}}>
{cell.day}
</span>
{/* Status */}
{!dayData ? (
<span style={{ fontSize: "0.65rem", color: "var(--color-text-dim)" }}></span>
) : isPH && isOpen ? (
<div style={{
background: "var(--color-ph-bg)",
border: "1px solid var(--color-ph-border)",
borderRadius: 4,
padding: "2px 5px",
}}>
<div style={{ fontSize: "0.55rem", fontWeight: 700, color: "var(--color-ph-label)", textTransform: "uppercase", letterSpacing: "0.05em" }}>
Passholder
</div>
<div style={{ fontSize: "0.6rem", color: "var(--color-ph-hours)", marginTop: 1 }}>
{dayData.hoursLabel}
</div>
</div>
) : isOpen ? (
<div style={{
background: "var(--color-open-bg)",
border: "1px solid var(--color-open-border)",
borderRadius: 4,
padding: "2px 5px",
}}>
<div style={{ fontSize: "0.6rem", color: "var(--color-open-hours)" }}>
{dayData.hoursLabel}
</div>
</div>
) : (
<span style={{ fontSize: "0.9rem", color: "var(--color-text-dim)", lineHeight: 1 }}>·</span>
)}
</div>
<div key={idx} style={{
overflow: "hidden",
background: ci === 0 || ci === 6 ? "var(--color-weekend-header)" : "transparent",
borderRight,
borderBottom,
}} />
);
})}
</div>
))}
}
const dayData = monthData[cell.iso];
const isToday = cell.iso === today;
const isWeekend = ci === 0 || ci === 6;
const isOpen = dayData?.isOpen && dayData?.hoursLabel;
const isPH = dayData?.specialType === "passholder_preview";
const bg = isToday
? "var(--color-today-bg)"
: isWeekend
? "var(--color-weekend-header)"
: "transparent";
return (
<div key={idx} style={{
padding: "8px 8px",
overflow: "hidden",
background: bg,
borderRight,
borderBottom,
borderLeft: isToday ? "2px solid var(--color-today-border)" : "none",
display: "flex",
flexDirection: "column",
gap: 6,
}}>
{/* Date number */}
<span style={{
fontSize: "0.88rem",
fontWeight: isToday ? 700 : isWeekend ? 600 : 400,
color: isToday
? "var(--color-today-text)"
: isWeekend
? "var(--color-text)"
: "var(--color-text-muted)",
lineHeight: 1,
flexShrink: 0,
}}>
{cell.day}
</span>
{/* ── Mobile status: colored dot only (sm:hidden) ──────── */}
{/* Cells are ~55px wide on mobile — no room for hours text */}
{!dayData ? (
<span className="sm:hidden" style={{ fontSize: "0.7rem", color: "var(--color-text-dim)" }}></span>
) : isOpen ? (
<div className="sm:hidden" style={{
width: 7, height: 7, borderRadius: "50%", flexShrink: 0,
background: isPH ? "var(--color-ph-border)" : "var(--color-open-border)",
}} />
) : null}
{/* ── Desktop status: full pill with hours (hidden sm:block) */}
{!dayData ? (
<span className="hidden sm:inline" style={{ fontSize: "0.75rem", color: "var(--color-text-dim)" }}></span>
) : isPH && isOpen ? (
<div className="hidden sm:block" style={{
background: "var(--color-ph-bg)",
border: "1px solid var(--color-ph-border)",
borderRadius: 5,
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: 4 }}>
{dayData.hoursLabel}
</div>
<div style={{ fontSize: "0.58rem", color: "var(--color-ph-label)", opacity: 0.75, marginTop: 3, letterSpacing: "0.04em" }}>
{tzAbbr}
</div>
</div>
) : isOpen ? (
<div className="hidden sm:block" style={{
background: "var(--color-open-bg)",
border: "1px solid var(--color-open-border)",
borderRadius: 5,
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: 4, letterSpacing: "0.04em" }}>
{tzAbbr}
</div>
</div>
) : (
<span className="hidden sm:inline" style={{ fontSize: "1rem", color: "var(--color-text-dim)", lineHeight: 1 }}>·</span>
)}
</div>
);
})}
</div>
</div>
</div>
);
@@ -215,12 +247,13 @@ const navLinkStyle: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
padding: "6px 14px",
borderRadius: 6,
padding: "10px 16px",
borderRadius: 8,
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
color: "var(--color-text-muted)",
fontSize: "1rem",
lineHeight: 1,
textDecoration: "none",
minWidth: 44,
};

View File

@@ -3,12 +3,18 @@ import Link from "next/link";
import type { Park } from "@/lib/scrapers/types";
import type { DayData } from "@/lib/db";
import type { Region } from "@/lib/parks";
import { getTodayLocal, getTimezoneAbbr } from "@/lib/env";
interface WeekCalendarProps {
parks: Park[];
weekDates: string[]; // 7 dates, YYYY-MM-DD, SunSat
data: Record<string, Record<string, DayData>>; // parkId → date → DayData
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;
openParkIds?: string[];
closingParkIds?: string[];
weatherDelayParkIds?: string[];
}
const DOW = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
@@ -29,12 +35,12 @@ function parseDate(iso: string) {
function DayCell({
dayData,
isToday,
isWeekend,
tzAbbr,
}: {
dayData: DayData | undefined;
isToday: boolean;
isWeekend: boolean;
tzAbbr: string;
}) {
const base: React.CSSProperties = {
padding: 0,
@@ -42,15 +48,8 @@ function DayCell({
verticalAlign: "middle",
borderBottom: "1px solid var(--color-border)",
borderLeft: "1px solid var(--color-border)",
height: 56,
background: isToday
? "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,
height: 72,
background: isWeekend ? "var(--color-weekend-header)" : "transparent",
transition: "background 120ms ease",
};
@@ -72,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)",
@@ -99,13 +98,21 @@ function DayCell({
</span>
<span style={{
color: "var(--color-ph-hours)",
fontSize: "0.7rem",
fontWeight: 500,
fontSize: "0.78rem",
fontWeight: 600,
letterSpacing: "-0.01em",
whiteSpace: "nowrap",
}}>
{dayData.hoursLabel}
</span>
<span style={{
color: "var(--color-ph-label)",
fontSize: "0.6rem",
fontWeight: 500,
letterSpacing: "0.04em",
}}>
{tzAbbr}
</span>
</div>
</td>
);
@@ -117,23 +124,34 @@ function DayCell({
background: "var(--color-open-bg)",
border: "1px solid var(--color-open-border)",
borderRadius: 6,
padding: "6px 4px",
padding: "4px",
textAlign: "center",
height: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 2,
transition: "filter 150ms ease",
}}>
<span style={{
color: "var(--color-open-hours)",
fontSize: "0.7rem",
fontWeight: 500,
fontSize: "0.78rem",
fontWeight: 600,
letterSpacing: "-0.01em",
whiteSpace: "nowrap",
}}>
{dayData.hoursLabel}
</span>
<span style={{
color: "var(--color-open-hours)",
fontSize: "0.6rem",
fontWeight: 500,
opacity: 0.6,
letterSpacing: "0.04em",
}}>
{tzAbbr}
</span>
</div>
</td>
);
@@ -148,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>
@@ -171,16 +188,28 @@ function ParkRow({
weekDates,
parsedDates,
parkData,
today,
rideCounts,
coastersOnly,
openParkIds,
closingParkIds,
weatherDelayParkIds,
}: {
park: Park;
parkIdx: number;
weekDates: string[];
parsedDates: ReturnType<typeof parseDate>[];
parkData: Record<string, DayData>;
today: string;
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"
@@ -191,38 +220,62 @@ function ParkRow({
position: "sticky",
left: 0,
zIndex: 5,
padding: "10px 14px",
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",
}}>
<Link href={`/park/${park.id}`} className="park-name-link">
<span style={{ fontWeight: 500, fontSize: "0.85rem", lineHeight: 1.2 }}>
{park.name}
</span>
<Link href={`/park/${park.id}`} className="park-name-link" style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "10px 14px",
gap: 10,
}}>
<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)", whiteSpace: "nowrap" }}>
{park.name}
</span>
</div>
<div style={{ fontSize: "0.7rem", color: "var(--color-text-muted)", marginTop: 2 }}>
{park.location.city}, {park.location.state}
</div>
</div>
{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
</div>
)}
</Link>
<div style={{ fontSize: "0.7rem", color: "var(--color-text-muted)", marginTop: 2 }}>
{park.location.city}, {park.location.state}
</div>
</td>
{weekDates.map((date, i) => (
<DayCell
key={date}
dayData={parkData[date]}
isToday={date === today}
isWeekend={parsedDates[i].isWeekend}
tzAbbr={tzAbbr}
/>
))}
</tr>
);
}
export function WeekCalendar({ parks, weekDates, data, grouped }: WeekCalendarProps) {
const today = new Date().toISOString().slice(0, 10);
export function WeekCalendar({ parks, weekDates, data, grouped, rideCounts, coastersOnly, openParkIds, closingParkIds, weatherDelayParkIds }: WeekCalendarProps) {
const today = getTodayLocal();
const parsedDates = weekDates.map(parseDate);
const firstMonth = parsedDates[0].month;
@@ -236,7 +289,7 @@ export function WeekCalendar({ parks, weekDates, data, grouped }: WeekCalendarPr
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%",
@@ -335,7 +388,11 @@ export function WeekCalendar({ parks, weekDates, data, grouped }: WeekCalendarPr
weekDates={weekDates}
parsedDates={parsedDates}
parkData={data[park.id] ?? {}}
today={today}
rideCounts={rideCounts}
coastersOnly={coastersOnly}
openParkIds={openParkIds}
closingParkIds={closingParkIds}
weatherDelayParkIds={weatherDelayParkIds}
/>
))}
</Fragment>

View File

@@ -1,5 +1,6 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
interface WeekNavProps {
@@ -32,8 +33,19 @@ function shiftWeek(weekStart: string, delta: number): string {
export function WeekNav({ weekStart, weekDates, isCurrentWeek }: WeekNavProps) {
const router = useRouter();
const nav = (delta: number) =>
const nav = (delta: number) => {
router.push(`/?week=${shiftWeek(weekStart, delta)}`);
};
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
if (e.key === "ArrowLeft") nav(-1);
if (e.key === "ArrowRight") nav(1);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [weekStart]);
return (
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
@@ -63,7 +75,7 @@ export function WeekNav({ weekStart, weekDates, isCurrentWeek }: WeekNavProps) {
fontSize: "1rem",
fontWeight: 600,
color: "var(--color-text)",
minWidth: 200,
minWidth: 140,
textAlign: "center",
letterSpacing: "-0.01em",
fontVariantNumeric: "tabular-nums",
@@ -85,8 +97,8 @@ export function WeekNav({ weekStart, weekDates, isCurrentWeek }: WeekNavProps) {
}
const navBtnStyle: React.CSSProperties = {
padding: "6px 14px",
borderRadius: 6,
padding: "10px 16px",
borderRadius: 8,
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
color: "var(--color-text-muted)",
@@ -94,11 +106,13 @@ const navBtnStyle: React.CSSProperties = {
fontSize: "1rem",
lineHeight: 1,
transition: "background 150ms ease, border-color 150ms ease, color 150ms ease",
minWidth: 44,
textAlign: "center",
};
const navBtnHover: React.CSSProperties = {
padding: "6px 14px",
borderRadius: 6,
padding: "10px 16px",
borderRadius: 8,
border: "1px solid var(--color-text-dim)",
background: "var(--color-surface-2)",
color: "var(--color-text-secondary)",
@@ -106,6 +120,8 @@ const navBtnHover: React.CSSProperties = {
fontSize: "1rem",
lineHeight: 1,
transition: "background 150ms ease, border-color 150ms ease, color 150ms ease",
minWidth: 44,
textAlign: "center",
};
const todayBtnStyle: React.CSSProperties = {

416
data/park-meta.json Normal file
View File

@@ -0,0 +1,416 @@
{
"greatadventure": {
"rcdb_id": 4534,
"coasters": [
"Superman - Ultimate Flight",
"El Toro",
"Dark Knight",
"Joker",
"Jersey Devil Coaster",
"Lil' Devil Coaster",
"Flash: Vertical Velocity",
"Batman The Ride",
"Skull Mountain",
"Runaway Mine Train",
"Medusa",
"Harley Quinn Crazy Train",
"Nitro"
],
"coasters_scraped_at": "2026-04-04T17:40:09.731Z"
},
"magicmountain": {
"rcdb_id": 4532,
"coasters": [
"Ninja",
"New Revolution",
"Batman The Ride",
"Viper",
"Gold Rusher",
"Riddler's Revenge",
"Canyon Blaster",
"Goliath",
"X2",
"Scream!",
"Tatsu",
"Apocalypse the Ride",
"Road Runner Express",
"Speedy Gonzales Hot Rod Racers",
"Full Throttle",
"Twisted Colossus",
"West Coast Racers",
"Wonder Woman Flight of Courage"
],
"coasters_scraped_at": "2026-04-04T17:45:43.666Z"
},
"greatamerica": {
"rcdb_id": 4530,
"coasters": [
"Demon",
"Batman The Ride",
"American Eagle",
"Viper",
"Whizzer",
"Sprocket Rockets",
"Raging Bull",
"Flash: Vertical Velocity",
"Superman - Ultimate Flight",
"Dark Knight",
"Little Dipper",
"Goliath",
"X-Flight",
"Joker",
"Maxx Force",
"Wrath of Rakshasa"
],
"coasters_scraped_at": "2026-04-04T17:29:24.092Z"
},
"overgeorgia": {
"rcdb_id": 4535,
"coasters": [
"Blue Hawk",
"Great American Scream Machine",
"Dahlonega Mine Train",
"Batman The Ride",
"Georgia Scorcher",
"Superman - Ultimate Flight",
"Joker Funhouse Coaster",
"Goliath",
"Dare Devil Dive",
"Twisted Cyclone",
"Riddler Mindbender",
"Georgia Gold Rusher"
],
"coasters_scraped_at": "2026-04-04T17:29:26.121Z"
},
"overtexas": {
"rcdb_id": 4531,
"coasters": [
"Pandemonium",
"New Texas Giant",
"Joker",
"Aquaman: Power Wave",
"Shock Wave",
"Judge Roy Scream",
"Runaway Mine Train",
"Runaway Mountain",
"Mini Mine Train",
"Mr. Freeze",
"Batman The Ride",
"Titan",
"Wile E. Coyote's Grand Canyon Blaster"
],
"coasters_scraped_at": "2026-04-04T17:45:45.715Z"
},
"stlouis": {
"rcdb_id": 4536,
"coasters": [
"Ninja",
"River King Mine Train",
"Mr. Freeze Reverse Blast",
"Batman The Ride",
"Screamin' Eagle",
"Boss",
"Pandemonium",
"American Thunder",
"Boomerang",
"Rookie Racer"
],
"coasters_scraped_at": "2026-04-04T17:45:47.770Z"
},
"fiestatexas": {
"rcdb_id": 4538,
"coasters": [
"Batgirl Coaster Chase",
"Road Runner Express",
"Poltergeist",
"Boomerang Coast to Coaster",
"Superman Krypton Coaster",
"Pandemonium",
"Chupacabra",
"Iron Rattler",
"Batman The Ride",
"Wonder Woman Golden Lasso Coaster",
"Dr. Diabolical's Cliffhanger"
],
"coasters_scraped_at": "2026-04-04T17:45:49.819Z"
},
"newengland": {
"rcdb_id": 4565,
"coasters": [
"Joker",
"Thunderbolt",
"Great Chase",
"Riddler Revenge",
"Superman the Ride",
"Flashback",
"Catwoman's Whip",
"Pandemonium",
"Batman - The Dark Knight",
"Wicked Cyclone",
"Gotham City Gauntlet Escape from Arkham Asylum"
],
"coasters_scraped_at": "2026-04-04T17:45:51.866Z"
},
"discoverykingdom": {
"rcdb_id": 4711,
"coasters": [
"Roadrunner Express",
"Medusa",
"Cobra",
"Flash: Vertical Velocity",
"Kong",
"Boomerang",
"Superman Ultimate Flight",
"Joker",
"Batman The Ride",
"Sidewinder Safari"
],
"coasters_scraped_at": "2026-04-04T17:45:53.909Z"
},
"mexico": {
"rcdb_id": 4629,
"coasters": [
"Tsunami",
"Superman Krypton Coaster",
"Batgirl Batarang",
"Batman The Ride",
"Superman el Último Escape",
"Dark Knight",
"Joker",
"Medusa Steel Coaster",
"Wonder Woman",
"Speedway Stunt Coaster"
],
"coasters_scraped_at": "2026-04-04T17:45:55.963Z"
},
"greatescape": {
"rcdb_id": 4596,
"coasters": [
"Comet",
"Steamin' Demon",
"Flashback",
"Canyon Blaster",
"Frankie's Mine Train",
"Bobcat"
],
"coasters_scraped_at": "2026-04-04T17:45:58.013Z"
},
"darienlake": {
"rcdb_id": 4581,
"coasters": [
"Predator",
"Viper",
"Mind Eraser",
"Boomerang",
"Ride of Steel",
"Hoot N Holler",
"Moto Coaster",
"Tantrum"
],
"coasters_scraped_at": "2026-04-04T17:46:00.042Z"
},
"cedarpoint": {
"rcdb_id": 4529,
"coasters": [
"Raptor",
"Rougarou",
"Magnum XL-200",
"Blue Streak",
"Corkscrew",
"Gemini",
"Wilderness Run",
"Woodstock Express",
"Millennium Force",
"Iron Dragon",
"Cedar Creek Mine Ride",
"Maverick",
"GateKeeper",
"Valravn",
"Steel Vengeance",
"Top Thrill 2",
"Wild Mouse",
"Sirens Curse"
],
"coasters_scraped_at": "2026-04-04T17:46:02.082Z"
},
"knotts": {
"rcdb_id": 4546,
"coasters": [
"Jaguar!",
"GhostRider",
"Xcelerator",
"Silver Bullet",
"Sierra Sidewinder",
"Pony Express",
"Coast Rider",
"HangTime",
"Snoopys Tenderpaw Twister Coaster"
],
"coasters_scraped_at": "2026-04-04T17:46:04.120Z"
},
"canadaswonderland": {
"rcdb_id": 4539,
"coasters": [
"Flight Deck",
"Dragon Fyre",
"Mighty Canadian Minebuster",
"Wilde Beast",
"Ghoster Coaster",
"Thunder Run",
"Bat",
"Vortex",
"Taxi Jam",
"Fly",
"Silver Streak",
"Backlot Stunt Coaster",
"Behemoth",
"Leviathan",
"Wonder Mountain's Guardian",
"Yukon Striker",
"Snoopy's Racing Railway",
"AlpenFury"
],
"coasters_scraped_at": "2026-04-04T17:46:06.152Z"
},
"carowinds": {
"rcdb_id": 4542,
"coasters": [
"Carolina Cyclone",
"Woodstock Express",
"Carolina Goldrusher",
"Hurler",
"Vortex",
"Wilderness Run",
"Afterburn",
"Flying Cobras",
"Thunder Striker",
"Fury 325",
"Copperhead Strike",
"Snoopys Racing Railway",
"Ricochet",
"Kiddy Hawk"
],
"coasters_scraped_at": "2026-04-04T17:46:08.185Z"
},
"kingsdominion": {
"rcdb_id": 4544,
"coasters": [
"Racer 75",
"Woodstock Express",
"Grizzly",
"Flight of Fear",
"Reptilian",
"Great Pumpkin Coaster",
"Apple Zapple",
"Backlot Stunt Coaster",
"Dominator",
"Pantherian",
"Twisted Timbers",
"Tumbili",
"Rapterra"
],
"coasters_scraped_at": "2026-04-04T17:46:10.223Z"
},
"kingsisland": {
"rcdb_id": 4540,
"coasters": [
"Flight of Fear",
"Beast",
"Racer",
"Adventure Express",
"Woodstock Express",
"Bat",
"Great Pumpkin Coaster",
"Invertigo",
"Diamondback",
"Banshee",
"Orion",
"Mystic Timbers",
"Snoopy's Soap Box Racers",
"Woodstocks Air Rail",
"Queen City Stunt Coaster"
],
"coasters_scraped_at": "2026-04-04T17:46:12.251Z"
},
"valleyfair": {
"rcdb_id": 4552,
"coasters": [
"High Roller",
"Corkscrew",
"Excalibur",
"Wild Thing",
"Mad Mouse",
"Steel Venom",
"Renegade",
"Cosmic Coaster"
],
"coasters_scraped_at": "2026-04-04T17:46:14.298Z"
},
"worldsoffun": {
"rcdb_id": 4533,
"coasters": [
"Timber Wolf",
"Cosmic Coaster",
"Mamba",
"Spinning Dragons",
"Patriot",
"Prowler",
"Zambezi Zinger",
"Boomerang"
],
"coasters_scraped_at": "2026-04-04T17:46:16.328Z"
},
"miadventure": {
"rcdb_id": 4578,
"coasters": [
"Corkscrew",
"Wolverine Wildcat",
"Zach's Zoomer",
"Shivering Timbers",
"Mad Mouse",
"Thunderhawk",
"Woodstock Express"
],
"coasters_scraped_at": "2026-04-04T17:46:18.370Z"
},
"dorneypark": {
"rcdb_id": 4588,
"coasters": [
"Thunderhawk",
"Steel Force",
"Wild Mouse",
"Woodstock Express",
"Talon",
"Hydra the Revenge",
"Possessed",
"Iron Menace"
],
"coasters_scraped_at": "2026-04-04T17:46:20.413Z"
},
"cagreatamerica": {
"rcdb_id": 4541,
"coasters": [
"Demon",
"Grizzly",
"Woodstock Express",
"Patriot",
"Flight Deck",
"Lucy's Crabbie Cabbies",
"Psycho Mouse",
"Gold Striker",
"RailBlazer"
],
"coasters_scraped_at": "2026-04-04T17:46:22.465Z"
},
"frontiercity": {
"rcdb_id": 4559,
"coasters": [
"Silver Bullet",
"Wildcat",
"Diamondback",
"Steel Lasso",
"Frankie's Mine Train"
],
"coasters_scraped_at": "2026-04-04T17:46:24.519Z"
}
}

View File

@@ -1,6 +1,6 @@
services:
web:
image: gitea.thewrightserver.net/josh/sixflagssupercalendar:latest
image: gitea.thewrightserver.net/josh/sixflagssupercalendar:web
ports:
- "3000:3000"
volumes:
@@ -9,5 +9,16 @@ services:
- NODE_ENV=production
restart: unless-stopped
scraper:
image: gitea.thewrightserver.net/josh/sixflagssupercalendar:scraper
volumes:
- park_data:/app/data
environment:
- NODE_ENV=production
- TZ=America/New_York
- PARK_HOURS_STALENESS_HOURS=72
- COASTER_STALENESS_HOURS=720
restart: unless-stopped
volumes:
park_data:

64
lib/coaster-match.ts Normal file
View File

@@ -0,0 +1,64 @@
/**
* Coaster name matching — shared between the Queue-Times scraper and tests.
*
* Queue-Times and RCDB use different name conventions:
* - Trademark symbols (™ ® ©)
* - Leading "THE " prefixes
* - Possessives ("Catwoman's" vs "Catwoman")
* - Subtitles added or dropped ("Apocalypse" vs "Apocalypse the Ride")
* - Space-split brand words ("BAT GIRL" vs "Batgirl")
* - Conjunction-joined compound rides ("Joker y Harley Quinn" ≠ "Joker")
*/
// Words that join two ride names rather than extend one subtitle.
// When a prefix match is found and the next word is one of these,
// the longer name is a *different* ride, not a subtitle.
const CONJUNCTIONS = new Set(["y", "and", "&", "with", "de", "del", "e", "et"]);
/**
* Normalize a ride name for matching.
* Both sides (Queue-Times and RCDB) must be normalized with this function
* before any comparison so the transforms are symmetric.
*/
export function normalizeForMatch(name: string): string {
return name
.replace(/[\u2122\u00ae\u00a9™®©]/g, "") // strip ™ ® ©
.replace(/^the\s+/i, "") // strip leading "THE "
.replace(/['\u2019]s\b/gi, "") // strip possessives ('s / 's)
.replace(/[^\w\s]/g, " ") // all remaining punctuation → space
.replace(/\s+/g, " ")
.toLowerCase()
.trim();
}
/**
* Returns true when the Queue-Times ride name matches an entry in the RCDB
* coaster set (which must be built with normalizeForMatch).
*
* Matching strategy (in order):
* 1. Exact normalized match.
* 2. Compact (space-stripped) match — catches "BAT GIRL" vs "Batgirl".
* 3. Prefix match — the shorter normalized name is a prefix of the longer,
* minimum 5 chars, unless the next word after the prefix is a conjunction
* (which signals a compound ride name, not a subtitle).
*/
export function isCoasterMatch(qtName: string, coasterSet: Set<string>): boolean {
const norm = normalizeForMatch(qtName);
if (coasterSet.has(norm)) return true;
const compact = norm.replace(/\s/g, "");
for (const c of coasterSet) {
// Compact comparison
if (compact.length >= 5 && c.replace(/\s/g, "") === compact) return true;
// Prefix comparison
const shorter = norm.length <= c.length ? norm : c;
const longer = norm.length <= c.length ? c : norm;
if (shorter.length >= 5 && longer.startsWith(shorter)) {
const nextWord = longer.slice(shorter.length).trim().split(" ")[0];
if (!CONJUNCTIONS.has(nextWord)) return true;
}
}
return false;
}

View File

@@ -46,6 +46,10 @@ export function upsertDay(
hoursLabel?: string,
specialType?: string
) {
// 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.
//
// 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 (?, ?, ?, ?, ?, ?)
@@ -54,6 +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')
`).run(parkId, date, isOpen ? 1 : 0, hoursLabel ?? null, specialType ?? null, new Date().toISOString());
}
@@ -160,16 +165,33 @@ export function getMonthCalendar(
return result;
}
/** True if the DB already has at least one row for this park+month. */
const STALE_AFTER_MS = 7 * 24 * 60 * 60 * 1000; // 1 week
import { parseStalenessHours } from "./env";
const STALE_AFTER_MS = parseStalenessHours(process.env.PARK_HOURS_STALENESS_HOURS, 72) * 60 * 60 * 1000;
/** True if the DB has data for this park+month scraped within the last week. */
/**
* Returns true when the scraper should skip this park+month.
*
* Two reasons to skip:
* 1. The month is entirely in the past — the API will never return data for
* those dates again, so re-scraping wastes a call and risks nothing but
* wasted time. Historical records are preserved forever by upsertDay.
* 2. The month was scraped within the last 7 days — data is still fresh.
*/
export function isMonthScraped(
db: Database.Database,
parkId: string,
year: number,
month: number
): boolean {
// Compute the last calendar day of this month (avoids timezone issues).
const daysInMonth = new Date(year, month, 0).getDate();
const lastDay = `${year}-${String(month).padStart(2, "0")}-${String(daysInMonth).padStart(2, "0")}`;
const today = new Date().toISOString().slice(0, 10);
// Past month — history is locked in, no API data available, always skip.
if (lastDay < today) return true;
// Current/future month — skip only if recently scraped.
const prefix = `${year}-${String(month).padStart(2, "0")}`;
const row = db
.prepare(

97
lib/env.ts Normal file
View File

@@ -0,0 +1,97 @@
/**
* Environment variable helpers.
*/
/**
* Parse a staleness window from an env var string (interpreted as hours).
* Falls back to `defaultHours` when the value is missing, non-numeric,
* non-finite, or <= 0 — preventing NaN from silently breaking staleness checks.
*/
export function parseStalenessHours(envVar: string | undefined, defaultHours: number): number {
const parsed = parseInt(envVar ?? "", 10);
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 the short timezone abbreviation for a given IANA timezone,
* e.g. "America/Los_Angeles" → "PDT" or "PST".
*/
export function getTimezoneAbbr(timezone: string): string {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: timezone,
timeZoneName: "short",
}).formatToParts(new Date());
return parts.find((p) => p.type === "timeZoneName")?.value ?? "";
}
/**
* Returns true when the current time in the park's timezone is within
* the operating window (open time through 1 hour after close), based on
* a hoursLabel like "10am 6pm". Falls back to true when unparseable.
*
* Uses the park's IANA timezone so a Pacific park's "10am" is correctly
* compared to Pacific time regardless of where the server is running.
*/
export function isWithinOperatingWindow(hoursLabel: string, timezone: string): boolean {
return getOperatingStatus(hoursLabel, timezone) !== "closed";
}
/**
* Returns the park's current operating status relative to its scheduled hours:
* "open" — within the scheduled open window
* "closing" — past scheduled close but within the 1-hour wind-down buffer
* "closed" — outside the window entirely
* Falls back to "open" when the label can't be parsed.
*/
export function getOperatingStatus(hoursLabel: string, timezone: string): "open" | "closing" | "closed" {
const m = hoursLabel.match(
/^(\d+)(?::(\d+))?(am|pm)\s*[-]\s*(\d+)(?::(\d+))?(am|pm)$/i
);
if (!m) return "open";
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]);
// Get the current time in the park's local timezone.
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: timezone,
hour: "numeric",
minute: "2-digit",
hour12: false,
}).formatToParts(new Date());
const h = parseInt(parts.find((p) => p.type === "hour")?.value ?? "0", 10);
const min = parseInt(parts.find((p) => p.type === "minute")?.value ?? "0", 10);
const nowMin = (h % 24) * 60 + min;
if (nowMin >= openMin && nowMin <= closeMin) return "open";
if (nowMin > closeMin && nowMin <= closeMin + 60) return "closing";
return "closed";
}

67
lib/park-meta.ts Normal file
View File

@@ -0,0 +1,67 @@
/**
* park-meta.json — persisted alongside the SQLite DB in data/
*
* This file stores per-park metadata that doesn't belong in the schedule DB:
* - rcdb_id: user-supplied RCDB park ID (fills into https://rcdb.com/{id}.htm)
* - coasters: list of operating roller coaster names scraped from RCDB
* - coasters_scraped_at: ISO timestamp of last RCDB scrape
*
* discover.ts: ensures every park has a skeleton entry (rcdb_id null by default)
* scrape.ts: populates coasters[] for parks with a known rcdb_id (30-day staleness)
*/
import fs from "fs";
import path from "path";
const META_PATH = path.join(process.cwd(), "data", "park-meta.json");
export interface ParkMeta {
/** RCDB park page ID — user fills this in manually after discover creates the skeleton */
rcdb_id: number | null;
/** Operating roller coaster names scraped from RCDB */
coasters: string[];
/** ISO timestamp of when coasters was last scraped from RCDB */
coasters_scraped_at: string | null;
}
export type ParkMetaMap = Record<string, ParkMeta>;
export function readParkMeta(): ParkMetaMap {
try {
return JSON.parse(fs.readFileSync(META_PATH, "utf8")) as ParkMetaMap;
} catch {
return {};
}
}
export function writeParkMeta(meta: ParkMetaMap): void {
fs.mkdirSync(path.dirname(META_PATH), { recursive: true });
fs.writeFileSync(META_PATH, JSON.stringify(meta, null, 2) + "\n");
}
/** Default skeleton entry for a park that has never been configured. */
export function defaultParkMeta(): ParkMeta {
return { rcdb_id: null, coasters: [], coasters_scraped_at: null };
}
const COASTER_STALE_MS = parseStalenessHours(process.env.COASTER_STALENESS_HOURS, 720) * 60 * 60 * 1000;
/** Returns true when the coaster list needs to be re-scraped from RCDB. */
export function areCoastersStale(entry: ParkMeta): boolean {
if (!entry.coasters_scraped_at) return true;
return Date.now() - new Date(entry.coasters_scraped_at).getTime() > COASTER_STALE_MS;
}
import { normalizeForMatch } from "./coaster-match";
export { normalizeForMatch as normalizeRideName } from "./coaster-match";
import { parseStalenessHours } from "./env";
/**
* Returns a Set of normalized coaster names for fast membership checks.
* Returns null when no coaster data exists for the park.
*/
export function getCoasterSet(parkId: string, meta: ParkMetaMap): Set<string> | null {
const entry = meta[parkId];
if (!entry || entry.coasters.length === 0) return null;
return new Set(entry.coasters.map(normalizeForMatch));
}

35
lib/queue-times-map.ts Normal file
View File

@@ -0,0 +1,35 @@
/**
* Maps our internal park IDs to Queue-Times.com park IDs.
*
* API: https://queue-times.com/parks/{id}/queue_times.json
* Attribution required: "Powered by Queue-Times.com"
* See: https://queue-times.com/en-US/pages/api
*/
export const QUEUE_TIMES_IDS: Record<string, number> = {
// Six Flags branded parks
greatadventure: 37,
magicmountain: 32,
greatamerica: 38,
overgeorgia: 35,
overtexas: 34,
stlouis: 36,
fiestatexas: 39,
newengland: 43,
discoverykingdom: 33,
mexico: 47,
greatescape: 45,
darienlake: 281,
// Former Cedar Fair parks
cedarpoint: 50,
knotts: 61,
canadaswonderland: 58,
carowinds: 59,
kingsdominion: 62,
kingsisland: 60,
valleyfair: 68,
worldsoffun: 63,
miadventure: 70,
dorneypark: 69,
cagreatamerica: 57,
frontiercity: 282,
};

126
lib/scrapers/queuetimes.ts Normal file
View File

@@ -0,0 +1,126 @@
/**
* Queue-Times.com live ride status scraper.
*
* API: https://queue-times.com/parks/{id}/queue_times.json
* Updates every 5 minutes while the park is operating.
* Attribution required per their terms: "Powered by Queue-Times.com"
* See: https://queue-times.com/en-US/pages/api
*/
import { isCoasterMatch } from "../coaster-match";
const BASE = "https://queue-times.com/parks";
const HEADERS = {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
Accept: "application/json",
};
export interface LiveRide {
name: string;
isOpen: boolean;
waitMinutes: number;
lastUpdated: string; // ISO 8601
/** True when the ride name appears in the RCDB coaster list for this park. */
isCoaster: boolean;
}
export interface LiveRidesResult {
rides: LiveRide[];
/** ISO timestamp of when we fetched the data */
fetchedAt: string;
}
interface QTRide {
id: number;
name: string;
is_open: boolean;
wait_time: number;
last_updated: string;
}
interface QTLand {
id: number;
name: string;
rides: QTRide[];
}
interface QTResponse {
lands: QTLand[];
rides: QTRide[]; // top-level rides (usually empty, rides live in lands)
}
/**
* Fetch live ride open/closed status and wait times for a park.
*
* Returns null when:
* - The park has no Queue-Times mapping
* - The request fails
* - The response contains no rides
*
* Pass coasterNames (from RCDB static data) to classify rides accurately.
* Matching is case-insensitive. When coasterNames is null no ride is
* classified as a coaster and the "Coasters only" toggle is hidden.
*
* Pass revalidate (seconds) to control Next.js ISR cache lifetime.
* Defaults to 300s (5 min) to match Queue-Times update frequency.
*/
export async function fetchLiveRides(
queueTimesId: number,
coasterNames: Set<string> | null = null,
revalidate = 300,
): Promise<LiveRidesResult | null> {
const url = `${BASE}/${queueTimesId}/queue_times.json`;
try {
const res = await fetch(url, {
headers: HEADERS,
next: { revalidate },
signal: AbortSignal.timeout(10_000),
} as RequestInit & { next: { revalidate: number } });
if (!res.ok) return null;
const json = (await res.json()) as QTResponse;
const rides: LiveRide[] = [];
for (const land of json.lands ?? []) {
for (const r of land.rides ?? []) {
if (!r.name) continue;
rides.push({
name: r.name,
isOpen: r.is_open,
waitMinutes: r.wait_time ?? 0,
lastUpdated: r.last_updated,
isCoaster: coasterNames ? isCoasterMatch(r.name, coasterNames) : false,
});
}
}
// Also capture any top-level rides (rare but possible)
for (const r of json.rides ?? []) {
if (!r.name) continue;
rides.push({
name: r.name,
isOpen: r.is_open,
waitMinutes: r.wait_time ?? 0,
lastUpdated: r.last_updated,
isCoaster: coasterNames ? isCoasterMatch(r.name, coasterNames) : false,
});
}
if (rides.length === 0) return null;
// Open rides first, then alphabetical within each group
rides.sort((a, b) => {
if (a.isOpen !== b.isOpen) return a.isOpen ? -1 : 1;
return a.name.localeCompare(b.name);
});
return { rides, fetchedAt: new Date().toISOString() };
} catch {
return null;
}
}

91
lib/scrapers/rcdb.ts Normal file
View File

@@ -0,0 +1,91 @@
/**
* RCDB (Roller Coaster DataBase) scraper.
*
* Fetches a park's RCDB page (https://rcdb.com/{id}.htm) and extracts the
* names of operating roller coasters from the "Operating Roller Coasters"
* section.
*
* RCDB has no public API. This scraper reads the static HTML page.
* Please scrape infrequently (30-day staleness window) to be respectful.
*/
const BASE = "https://rcdb.com";
const HEADERS = {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
Accept: "text/html,application/xhtml+xml",
"Accept-Language": "en-US,en;q=0.9",
};
/**
* Scrape operating roller coaster names for a park.
*
* Returns an array of coaster names on success, or null when the page
* cannot be fetched or contains no operating coasters.
*/
export async function scrapeRcdbCoasters(rcdbId: number): Promise<string[] | null> {
const url = `${BASE}/${rcdbId}.htm`;
try {
const res = await fetch(url, { headers: HEADERS, signal: AbortSignal.timeout(15_000) });
if (!res.ok) {
console.error(` RCDB ${rcdbId}: HTTP ${res.status}`);
return null;
}
const html = await res.text();
return parseOperatingCoasters(html);
} catch (err) {
console.error(` RCDB ${rcdbId}: ${err}`);
return null;
}
}
/**
* Parse operating roller coaster names from RCDB park page HTML.
*
* RCDB park pages list coasters in sections bounded by <section> tags.
* The operating section heading looks like:
* <h4>Operating Roller Coasters: <a href="...">16</a></h4>
*
* Each coaster is an <a> link to its detail page with an unquoted href:
* <td data-sort="Batman The Ride"><a href=/5.htm>Batman The Ride</a>
*
* We extract only those links (href=/DIGITS.htm) from within the
* operating section, stopping at the next <section> tag.
*/
function parseOperatingCoasters(html: string): string[] {
// Find the "Operating Roller Coasters" section heading.
const opIdx = html.search(/Operating\s+Roller\s+Coasters/i);
if (opIdx === -1) return [];
// The section ends at the next <section> tag (e.g. "Defunct Roller Coasters").
const after = html.slice(opIdx);
const nextSection = after.search(/<section\b/i);
const sectionHtml = nextSection > 0 ? after.slice(0, nextSection) : after;
// Extract coaster names from links to RCDB detail pages.
// RCDB uses unquoted href attributes: href=/1234.htm
// General links (/g.htm, /r.htm, /location.htm, etc.) won't match \d+\.htm.
const names: string[] = [];
const linkPattern = /<a\s[^>]*href=["']?\/(\d+)\.htm["']?[^>]*>([^<]+)<\/a>/gi;
let match: RegExpExecArray | null;
while ((match = linkPattern.exec(sectionHtml)) !== null) {
const name = decodeHtmlEntities(match[2].trim());
if (name) names.push(name);
}
// Deduplicate while preserving order
return [...new Set(names)];
}
function decodeHtmlEntities(text: string): string {
return text
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#(\d+);/g, (_, code) => String.fromCharCode(parseInt(code, 10)))
.replace(/&[a-z]+;/gi, "");
}

View File

@@ -107,12 +107,12 @@ async function fetchApi(
): Promise<ApiResponse> {
const fetchOpts: RequestInit & { next?: { revalidate: number } } = { headers: HEADERS };
if (revalidate !== undefined) fetchOpts.next = { revalidate };
const res = await fetch(url, fetchOpts);
const res = await fetch(url, { ...fetchOpts, signal: AbortSignal.timeout(15_000) });
if (res.status === 429 || res.status === 503) {
const retryAfter = res.headers.get("Retry-After");
const waitMs = retryAfter
? parseInt(retryAfter) * 1000
? Math.min(parseInt(retryAfter, 10) * 1000, 5 * 60 * 1000) // cap at 5 min
: BASE_BACKOFF_MS * Math.pow(2, attempt);
console.log(
` [rate-limited] HTTP ${res.status} — waiting ${waitMs / 1000}s (attempt ${attempt + 1}/${MAX_RETRIES})`
@@ -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);
}
/**

View File

@@ -1,9 +1,34 @@
import type { NextConfig } from "next";
const CSP = [
"default-src 'self'",
"script-src 'self' 'unsafe-inline'", // Next.js requires unsafe-inline for hydration
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data:",
"font-src 'self'",
"connect-src 'self' https://queue-times.com",
"frame-ancestors 'none'",
].join("; ");
const nextConfig: NextConfig = {
// better-sqlite3 is a native module — must not be bundled by webpack
serverExternalPackages: ["better-sqlite3"],
output: "standalone",
async headers() {
return [
{
source: "/(.*)",
headers: [
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "X-Frame-Options", value: "DENY" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "Permissions-Policy", value: "geolocation=(), microphone=(), camera=()" },
{ key: "Content-Security-Policy", value: CSP },
],
},
];
},
};
export default nextConfig;

View File

@@ -10,7 +10,8 @@
"scrape": "tsx scripts/scrape.ts",
"scrape:force": "tsx scripts/scrape.ts --rescrape",
"discover": "tsx scripts/discover.ts",
"debug": "tsx scripts/debug.ts"
"debug": "tsx scripts/debug.ts",
"test": "tsx --test tests/*.test.ts"
},
"dependencies": {
"better-sqlite3": "^12.8.0",

View File

@@ -17,6 +17,7 @@ import { chromium } from "playwright";
import { openDb, getApiId, setApiId, type DbInstance } from "../lib/db";
import { PARKS } from "../lib/parks";
import { fetchParkInfo, isMainThemePark } from "../lib/scrapers/sixflags";
import { readParkMeta, writeParkMeta, defaultParkMeta } from "../lib/park-meta";
const CLOUDFRONT_PATTERN = /operating-hours\/park\/(\d+)/;
@@ -115,7 +116,6 @@ async function main() {
// Fetch full info to store name/abbreviation
const info = await fetchParkInfo(apiId);
setApiId(db, park.id, apiId, info?.parkAbbreviation, info?.parkName);
console.log(`done (ID ${apiId})`);
} catch (err) {
console.log(`ERROR: ${err}`);
}
@@ -124,11 +124,39 @@ async function main() {
await new Promise((r) => setTimeout(r, 2000));
}
// ── Ensure park-meta.json has a skeleton entry for every park ────────────
// Users fill in rcdb_id manually; scrape.ts populates coasters[] from RCDB.
const meta = readParkMeta();
let metaChanged = false;
for (const park of PARKS) {
if (!meta[park.id]) {
meta[park.id] = defaultParkMeta();
metaChanged = true;
}
}
// Remove entries for parks no longer in the registry
for (const id of Object.keys(meta)) {
if (!PARKS.find((p) => p.id === id)) {
delete meta[id];
metaChanged = true;
}
}
if (metaChanged) {
writeParkMeta(meta);
console.log("\nUpdated data/park-meta.json");
console.log(" → Set rcdb_id for each park to enable the coaster filter.");
console.log(" Find a park's RCDB ID from: https://rcdb.com (the number in the URL).");
}
// Print summary
console.log("\n── Discovered IDs ──");
for (const park of PARKS) {
const id = getApiId(db, park.id);
console.log(` ${park.id.padEnd(30)} ${id ?? "NOT FOUND"}`);
const rcdbId = meta[park.id]?.rcdb_id;
const rcdbStr = rcdbId ? `rcdb:${rcdbId}` : "rcdb:?";
console.log(` ${park.id.padEnd(30)} api:${String(id ?? "?").padEnd(8)} ${rcdbStr}`);
}
db.close();

View File

@@ -0,0 +1,45 @@
#!/bin/sh
# Nightly scraper scheduler — runs inside the Docker scraper service.
#
# Behaviour:
# 1. Runs an initial scrape immediately on container start.
# 2. Sleeps until 3:00 AM (container timezone, set via TZ env var).
# 3. Runs the scraper, then sleeps until the next 3:00 AM, forever.
#
# Timezone: set TZ in the scraper service environment to control when
# "3am" is (e.g. TZ=America/New_York). Defaults to UTC if unset.
log() {
echo "[scheduler] $(date '+%Y-%m-%d %H:%M %Z')$*"
}
run_scrape() {
log "Starting scrape"
if npm run scrape; then
log "Scrape completed"
else
log "Scrape failed — will retry at next scheduled time"
fi
}
seconds_until_3am() {
now=$(date +%s)
# Try today's 3am first; if already past, use tomorrow's.
target=$(date -d "today 03:00" +%s)
if [ "$now" -ge "$target" ]; then
target=$(date -d "tomorrow 03:00" +%s)
fi
echo $((target - now))
}
# ── Run immediately on startup ────────────────────────────────────────────────
run_scrape
# ── Nightly loop ──────────────────────────────────────────────────────────────
while true; do
wait=$(seconds_until_3am)
next=$(date -d "now + ${wait} seconds" '+%Y-%m-%d %H:%M %Z')
log "Next scrape in $((wait / 3600))h $((( wait % 3600) / 60))m (${next})"
sleep "$wait"
run_scrape
done

View File

@@ -9,7 +9,9 @@
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";
const YEAR = 2026;
const MONTHS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
@@ -98,7 +100,62 @@ 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) ────────────────────────────────
const meta = readParkMeta();
const rcdbParks = PARKS.filter((p) => {
const entry = meta[p.id];
return entry?.rcdb_id && (FORCE || areCoastersStale(entry));
});
if (rcdbParks.length === 0) {
console.log("\nCoaster data up to date.");
return;
}
console.log(`\n── RCDB coaster scrape — ${rcdbParks.length} park(s) ──`);
for (const park of rcdbParks) {
const entry = meta[park.id];
const rcdbId = entry.rcdb_id!;
process.stdout.write(` ${park.shortName.padEnd(30)} `);
const coasters = await scrapeRcdbCoasters(rcdbId);
if (coasters === null) {
console.log("FAILED");
continue;
}
entry.coasters = coasters;
entry.coasters_scraped_at = new Date().toISOString();
console.log(`${coasters.length} coasters`);
// Polite delay between RCDB requests
await new Promise((r) => setTimeout(r, 2000));
}
writeParkMeta(meta);
console.log(" Saved to data/park-meta.json");
}
main().catch((err) => {

View File

@@ -0,0 +1,51 @@
/**
* Coaster name matching tests.
*
* Each entry is a real case found between Queue-Times and RCDB names.
* Add new cases here when fixing a mismatch or false positive.
*
* Run with: npm test
*/
import { test } from "node:test";
import assert from "node:assert/strict";
import { isCoasterMatch, normalizeForMatch } from "../lib/coaster-match";
function set(...rcdbNames: string[]): Set<string> {
return new Set(rcdbNames.map(normalizeForMatch));
}
// ── Should match ─────────────────────────────────────────────────────────────
const SHOULD_MATCH: [qtName: string, rcdbName: string, park: string][] = [
["BATMAN™ The Ride", "Batman The Ride", "Over Georgia / Magic Mountain"],
["THE RIDDLER Mindbender", "Riddler Mindbender", "Over Georgia"],
["THE RIDDLER™'s Revenge", "Riddler's Revenge", "Magic Mountain"],
["CATWOMAN™ Whip", "Catwoman's Whip", "New England"],
["SUPERMAN™: Ultimate Flight", "Superman - Ultimate Flight", "Over Georgia"],
["THE JOKER™ Funhouse Coaster", "Joker Funhouse Coaster", "Over Georgia"],
["The Great American Scream Machine", "Great American Scream Machine", "Over Georgia"],
["Apocalypse", "Apocalypse the Ride", "Magic Mountain"],
["The New Revolution - Classic", "New Revolution", "Magic Mountain"],
["SCREAM", "Scream!", "Magic Mountain"],
["BAT GIRL™: Coaster Chase", "Batgirl Coaster Chase", "Fiesta Texas"],
["THE JOKER™ 4D Free Fly Coaster", "Joker", "New England"],
];
for (const [qt, rcdb, park] of SHOULD_MATCH) {
test(`match: "${qt}" = "${rcdb}" (${park})`, () => {
assert.ok(isCoasterMatch(qt, set(rcdb)), `Expected match`);
});
}
// ── Should NOT match (false positives) ───────────────────────────────────────
const SHOULD_NOT_MATCH: [qtName: string, rcdbName: string, park: string][] = [
["Joker y Harley Quinn", "Joker", "Six Flags Mexico"],
];
for (const [qt, rcdb, park] of SHOULD_NOT_MATCH) {
test(`no match: "${qt}" ≠ "${rcdb}" (${park})`, () => {
assert.ok(!isCoasterMatch(qt, set(rcdb)), `Expected no match`);
});
}