Compare commits

...

54 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
15 changed files with 964 additions and 362 deletions

View File

@@ -27,6 +27,18 @@
--color-open-text: #4ade80;
--color-open-hours: #bbf7d0;
/* ── Weather delay — blue (open by schedule but all rides closed) ───────── */
--color-weather-bg: #0a1020;
--color-weather-border: #3b82f6;
--color-weather-text: #60a5fa;
--color-weather-hours: #bfdbfe;
/* ── Closing — amber (post-close buffer, rides still winding down) ───────── */
--color-closing-bg: #1a1100;
--color-closing-border: #d97706;
--color-closing-text: #fbbf24;
--color-closing-hours: #fde68a;
/* ── Passholder preview — vivid cyan ─────────────────────────────────────── */
--color-ph-bg: #051518;
--color-ph-border: #22d3ee;
@@ -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

@@ -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,100 +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 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>
<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 (legend hidden on mobile) */}
<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}
/>
</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,15 +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 }>;
@@ -23,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) {
@@ -34,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();
@@ -42,7 +45,13 @@ export default async function ParkPage({ params, searchParams }: PageProps) {
const apiId = getApiId(db, id);
db.close();
const todayData = monthData[today];
// Prefer live today data from the Six Flags API (5-min ISR cache) so that
// weather delays and hour changes surface immediately rather than showing
// stale DB values. Fall back to DB if the API call fails.
const liveToday = apiId !== null ? await fetchToday(apiId, 300).catch(() => null) : null;
const todayData = liveToday
? { isOpen: liveToday.isOpen, hoursLabel: liveToday.hoursLabel ?? null, specialType: liveToday.specialType ?? null }
: monthData[today];
const parkOpenToday = todayData?.isOpen && todayData?.hoursLabel;
// ── Ride data: try live Queue-Times first, fall back to schedule ──────────
@@ -53,14 +62,33 @@ export default async function ParkPage({ params, searchParams }: PageProps) {
let liveRides: LiveRidesResult | null = null;
let ridesResult: RidesFetchResult | null = null;
// Determine if we're within the 1h-before-open to 1h-after-close window.
const withinWindow = todayData?.hoursLabel
? isWithinOperatingWindow(todayData.hoursLabel, park.timezone)
: false;
if (queueTimesId) {
liveRides = await fetchLiveRides(queueTimesId, coasterSet);
const raw = await fetchLiveRides(queueTimesId, coasterSet);
if (raw) {
// Outside the window: show the ride list but force all rides closed
liveRides = withinWindow
? raw
: {
...raw,
rides: raw.rides.map((r) => ({ ...r, isOpen: false, waitMinutes: 0 })),
};
}
}
// Only hit the schedule API as a fallback when live data is unavailable
// Weather delay: park is within operating hours but queue-times shows 0 open rides
const isWeatherDelay =
withinWindow &&
liveRides !== null &&
liveRides.rides.length > 0 &&
liveRides.rides.every((r) => !r.isOpen);
// Only hit the schedule API as a fallback when Queue-Times live data is unavailable.
if (!liveRides && apiId !== null) {
// Note: the API drops today's date from its response (only returns future dates),
// so scrapeRidesForDay may fall back to the nearest upcoming date.
ridesResult = await scrapeRidesForDay(apiId, today);
}
@@ -78,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}
@@ -110,12 +126,31 @@ 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
{liveRides ? (
<LiveBadge />
@@ -134,6 +169,7 @@ export default async function ParkPage({ params, searchParams }: PageProps) {
<LiveRidePanel
liveRides={liveRides}
parkOpenToday={!!parkOpenToday}
isWeatherDelay={isWeatherDelay}
/>
) : (
<RideList
@@ -158,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)",
@@ -175,9 +211,12 @@ function SectionHeading({ children }: { children: React.ReactNode }) {
letterSpacing: "0.04em",
textTransform: "uppercase",
margin: 0,
display: "flex",
alignItems: "center",
}}>
{children}
</h2>
{aside}
</div>
);
}

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

@@ -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

@@ -1,18 +1,26 @@
"use client";
import { useState } from "react";
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 }: LiveRidePanelProps) {
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);
@@ -42,6 +50,19 @@ export function LiveRidePanel({ liveRides, parkOpenToday }: LiveRidePanelProps)
}}>
{openRides.length} open
</div>
) : isWeatherDelay ? (
<div style={{
background: "var(--color-weather-bg)",
border: "1px solid var(--color-weather-border)",
borderRadius: 20,
padding: "4px 12px",
fontSize: "0.72rem",
fontWeight: 600,
color: "var(--color-weather-text)",
flexShrink: 0,
}}>
Weather Delay all rides currently closed
</div>
) : (
<div style={{
background: "var(--color-surface)",
@@ -57,8 +78,8 @@ export function LiveRidePanel({ liveRides, parkOpenToday }: LiveRidePanelProps)
</div>
)}
{/* Closed count badge */}
{anyOpen && closedRides.length > 0 && (
{/* 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)",
@@ -69,7 +90,7 @@ export function LiveRidePanel({ liveRides, parkOpenToday }: LiveRidePanelProps)
color: "var(--color-text-muted)",
flexShrink: 0,
}}>
{closedRides.length} closed / down
{closedRides.length} {anyOpen ? "closed / down" : "rides total"}
</div>
)}
@@ -115,26 +136,6 @@ export function LiveRidePanel({ liveRides, parkOpenToday }: LiveRidePanelProps)
{closedRides.map((ride) => <RideRow key={ride.name} ride={ride} />)}
</div>
{/* ── Attribution ──────────────────────────────────────────────────── */}
<div style={{
marginTop: 20,
fontSize: "0.68rem",
color: "var(--color-text-dim)",
display: "flex",
alignItems: "center",
gap: 4,
}}>
Powered by{" "}
<a
href="https://queue-times.com"
target="_blank"
rel="noopener noreferrer"
style={{ color: "var(--color-text-muted)", textDecoration: "underline" }}
>
Queue-Times.com
</a>
{" "}· Updates every 5 minutes
</div>
</div>
);
}

View File

@@ -8,9 +8,14 @@ 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: 20, paddingTop: 14 }}>
{Array.from(grouped.entries()).map(([region, parks]) => (
@@ -19,25 +24,17 @@ export function MobileCardList({ grouped, weekDates, data, today }: MobileCardLi
<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,18 +1,25 @@
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, SunSat
parkData: Record<string, DayData>;
today: string;
openRideCount?: number;
coastersOnly?: boolean;
isOpen?: boolean;
isClosing?: boolean;
isWeatherDelay?: boolean;
}
const DOW = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
export function ParkCard({ park, weekDates, parkData, today }: ParkCardProps) {
export function ParkCard({ park, weekDates, parkData, today, openRideCount, coastersOnly, isOpen, isClosing, isWeatherDelay }: ParkCardProps) {
const openDays = weekDates.filter((d) => parkData[d]?.isOpen && parkData[d]?.hoursLabel);
const tzAbbr = getTimezoneAbbr(park.timezone);
const isOpenToday = openDays.includes(today);
return (
@@ -21,12 +28,14 @@ export function ParkCard({ park, weekDates, parkData, today }: ParkCardProps) {
data-park={park.name.toLowerCase()}
style={{ textDecoration: "none", display: "block" }}
>
<div style={{
<div className="park-card" style={{
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
borderLeft: isOpen
? `3px solid ${isWeatherDelay ? "var(--color-weather-border)" : isClosing ? "var(--color-closing-border)" : "var(--color-open-border)"}`
: "1px solid var(--color-border)",
borderRadius: 12,
overflow: "hidden",
transition: "border-color 120ms ease",
}}>
{/* ── Card header ───────────────────────────────────────────────────── */}
<div style={{
@@ -34,9 +43,10 @@ export function ParkCard({ park, weekDates, parkData, today }: ParkCardProps) {
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
flexWrap: "wrap",
gap: 12,
}}>
<div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: "0.95rem",
fontWeight: 600,
@@ -54,20 +64,20 @@ export function ParkCard({ park, weekDates, parkData, today }: ParkCardProps) {
</div>
</div>
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 5 }}>
{isOpenToday ? (
<div style={{
background: "var(--color-open-bg)",
border: "1px solid var(--color-open-border)",
background: isWeatherDelay ? "var(--color-weather-bg)" : isClosing ? "var(--color-closing-bg)" : "var(--color-open-bg)",
border: `1px solid ${isWeatherDelay ? "var(--color-weather-border)" : isClosing ? "var(--color-closing-border)" : "var(--color-open-border)"}`,
borderRadius: 20,
padding: "4px 10px",
fontSize: "0.65rem",
fontWeight: 700,
color: "var(--color-open-text)",
color: isWeatherDelay ? "var(--color-weather-text)" : isClosing ? "var(--color-closing-text)" : "var(--color-open-text)",
whiteSpace: "nowrap",
flexShrink: 0,
letterSpacing: "0.03em",
}}>
Open today
{isWeatherDelay ? "⛈ Weather Delay" : isClosing ? "Closing" : "Open today"}
</div>
) : (
<div style={{
@@ -79,11 +89,33 @@ export function ParkCard({ park, weekDates, parkData, today }: ParkCardProps) {
fontWeight: 500,
color: "var(--color-text-muted)",
whiteSpace: "nowrap",
flexShrink: 0,
}}>
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>
{/* ── Open days list ────────────────────────────────────────────────── */}
@@ -121,10 +153,10 @@ export function ParkCard({ park, weekDates, parkData, today }: ParkCardProps) {
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
{isPH && (
<span style={{
fontSize: "0.58rem",
fontSize: "0.65rem",
fontWeight: 700,
color: "var(--color-ph-label)",
letterSpacing: "0.05em",
letterSpacing: "0.04em",
textTransform: "uppercase",
}}>
Passholder
@@ -139,7 +171,10 @@ export function ParkCard({ park, weekDates, parkData, today }: ParkCardProps) {
? "var(--color-today-text)"
: "var(--color-open-hours)",
}}>
{dayData.hoursLabel}
{dayData.hoursLabel}{" "}
<span style={{ fontSize: "0.68rem", fontWeight: 400, color: "var(--color-text-dim)" }}>
{tzAbbr}
</span>
</span>
</div>
</div>

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: 96,
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: 96,
padding: "10px 12px",
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: 5,
}}>
{/* Date number */}
<span style={{
fontSize: "0.95rem",
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.75rem", color: "var(--color-text-dim)" }}></span>
) : isPH && isOpen ? (
<div style={{
background: "var(--color-ph-bg)",
border: "1px solid var(--color-ph-border)",
borderRadius: 5,
padding: "3px 6px",
}}>
<div style={{ fontSize: "0.6rem", fontWeight: 700, color: "var(--color-ph-label)", textTransform: "uppercase", letterSpacing: "0.05em" }}>
Passholder
</div>
<div style={{ fontSize: "0.65rem", color: "var(--color-ph-hours)", marginTop: 2 }}>
{dayData.hoursLabel}
</div>
</div>
) : isOpen ? (
<div style={{
background: "var(--color-open-bg)",
border: "1px solid var(--color-open-border)",
borderRadius: 5,
padding: "3px 6px",
}}>
<div style={{ fontSize: "0.65rem", color: "var(--color-open-hours)" }}>
{dayData.hoursLabel}
</div>
</div>
) : (
<span style={{ fontSize: "1rem", 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

@@ -33,8 +33,9 @@ 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) => {
@@ -74,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",
@@ -96,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)",
@@ -105,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)",
@@ -117,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 = {

View File

@@ -46,12 +46,10 @@ export function upsertDay(
hoursLabel?: string,
specialType?: string
) {
// Today and past dates: INSERT new rows freely, but NEVER overwrite existing records.
// Once an operating day begins the API drops that date from its response, so a
// re-scrape would incorrectly record the day as closed. The DB row written when
// the date was still in the future is the permanent truth for that day.
// Today and future dates: full upsert — hours can change (e.g. weather delays,
// early closures) and the dateless API endpoint now returns today's live data.
//
// Future dates only: full upsert — hours can change and closures can be added.
// Past dates: INSERT-only — never overwrite once the day has passed.
db.prepare(`
INSERT INTO park_days (park_id, date, is_open, hours_label, special_type, scraped_at)
VALUES (?, ?, ?, ?, ?, ?)
@@ -60,7 +58,7 @@ export function upsertDay(
hours_label = excluded.hours_label,
special_type = excluded.special_type,
scraped_at = excluded.scraped_at
WHERE park_days.date > date('now')
WHERE park_days.date >= date('now')
`).run(parkId, date, isOpen ? 1 : 0, hoursLabel ?? null, specialType ?? null, new Date().toISOString());
}

View File

@@ -11,3 +11,87 @@ export function parseStalenessHours(envVar: string | undefined, defaultHours: nu
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";
}

View File

@@ -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

@@ -9,7 +9,7 @@
import { openDb, upsertDay, getApiId, isMonthScraped } from "../lib/db";
import { PARKS } from "../lib/parks";
import { scrapeMonth, RateLimitError } from "../lib/scrapers/sixflags";
import { scrapeMonth, fetchToday, RateLimitError } from "../lib/scrapers/sixflags";
import { readParkMeta, writeParkMeta, areCoastersStale } from "../lib/park-meta";
import { scrapeRcdbCoasters } from "../lib/scrapers/rcdb";
@@ -100,6 +100,25 @@ async function main() {
console.log(`\n ${totalFetched} fetched ${totalSkipped} skipped ${totalErrors} errors`);
if (totalErrors > 0) console.log(" Re-run to retry failed months.");
// ── Today scrape (always fresh — dateless endpoint returns current day) ────
console.log("\n── Today's data ──");
for (const park of ready) {
const apiId = getApiId(db, park.id)!;
process.stdout.write(` ${park.shortName.padEnd(22)} `);
try {
const today = await fetchToday(apiId);
if (today) {
upsertDay(db, park.id, today.date, today.isOpen, today.hoursLabel, today.specialType);
console.log(today.isOpen ? `open ${today.hoursLabel ?? ""}` : "closed");
} else {
console.log("no data");
}
} catch {
console.log("error");
}
await sleep(500);
}
db.close();
// ── RCDB coaster scrape (30-day staleness) ────────────────────────────────