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>
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>
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>
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>
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>
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>
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>
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>
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>
- 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>
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>
- "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>
- 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>
- 🎢 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>
- "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>
- 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>
- 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>
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>
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>
- 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>
- 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>
- 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>
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>
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>
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>
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>
- 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>
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>
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>
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>
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>
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>
- 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>
- 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>
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>
- 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>
- Remove unused formatDate() from park page (replaced by formatShortDate)
- Remove unused date prop from DayCell component
- Change let to const for cellBorderRadius in ParkCard
- Change let to const for bg in ParkMonthCalendar
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Visual overhaul:
- Warmer color system with amber accent for Today, better text hierarchy
- Row hover highlighting, sticky column shadow on horizontal scroll
- Closed cells replaced with dot (·) instead of "Closed" text
- Regional grouping (Northeast/Southeast/Midwest/Texas & South/West)
- Two-row header with park count badge and WeekNav on separate lines
- Amber "Today" button in WeekNav when off current week
- Mobile card layout (< 1024px) with 7-day grid per park; table on desktop
- Skeleton loading state via app/loading.tsx
Park detail pages (/park/[id]):
- Month calendar view with ← → navigation via ?month= param
- Live ride status fetched from Six Flags API (cached 1h)
- Ride hours only shown when they differ from park operating hours
- Fallback to nearest upcoming open day when today is dropped by API,
including cross-month fallback for end-of-month edge case
Data layer:
- Park type gains region field; parks.ts exports groupByRegion()
- db.ts gains getParkMonthData() for single-park month queries
- sixflags.ts gains scrapeRidesForDay() returning RidesFetchResult
with rides, dataDate, isExact, and parkHoursLabel
Removed: CalendarGrid.tsx, MonthNav.tsx (dead code)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>